Repository: laszlocph/woodpecker Branch: main Commit: f36a8272be5e Files: 1510 Total size: 7.4 MB Directory structure: gitextract_g04cg062/ ├── .cspell.json ├── .ecrc ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ ├── config.yml │ │ └── feature_request.yaml │ ├── pull_request_template.md │ ├── release_template.md │ └── renovate.json ├── .gitignore ├── .gitpod.yml ├── .golangci.yaml ├── .hadolint.yaml ├── .lycheeignore ├── .markdownlint.yaml ├── .mockery.yaml ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── .woodpecker/ │ ├── binaries.yaml │ ├── check-feature-docs.sh │ ├── docker.yaml │ ├── docs.yaml │ ├── links.yaml │ ├── release-helper.yaml │ ├── securityscan.yaml │ ├── social.yaml │ ├── static.yaml │ ├── test.yaml │ └── web.yaml ├── .yamllint.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── agent/ │ ├── log/ │ │ ├── line_writer.go │ │ └── line_writer_test.go │ ├── logger.go │ ├── rpc/ │ │ ├── auth_client_grpc.go │ │ ├── auth_client_grpc_test.go │ │ ├── auth_interceptor.go │ │ └── client_grpc.go │ ├── runner.go │ ├── state.go │ └── tracer.go ├── checkmake.ini ├── cli/ │ ├── README.md │ ├── admin/ │ │ ├── admin.go │ │ ├── loglevel/ │ │ │ └── loglevel.go │ │ ├── org/ │ │ │ └── org_list.go │ │ ├── registry/ │ │ │ ├── registry.go │ │ │ ├── registry_add.go │ │ │ ├── registry_list.go │ │ │ ├── registry_rm.go │ │ │ ├── registry_set.go │ │ │ └── registry_show.go │ │ ├── secret/ │ │ │ ├── secret.go │ │ │ ├── secret_add.go │ │ │ ├── secret_list.go │ │ │ ├── secret_rm.go │ │ │ ├── secret_set.go │ │ │ └── secret_show.go │ │ └── user/ │ │ ├── user.go │ │ ├── user_add.go │ │ ├── user_list.go │ │ ├── user_rm.go │ │ └── user_show.go │ ├── common/ │ │ ├── flags.go │ │ ├── hooks.go │ │ ├── pipeline.go │ │ └── zerologger.go │ ├── context/ │ │ └── context.go │ ├── exec/ │ │ ├── dummy.go │ │ ├── exec.go │ │ ├── flags.go │ │ ├── line.go │ │ ├── metadata.go │ │ └── metadata_test.go │ ├── info/ │ │ └── info.go │ ├── internal/ │ │ ├── config/ │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── context.go │ │ │ └── context_test.go │ │ ├── util.go │ │ └── util_test.go │ ├── lint/ │ │ ├── lint.go │ │ └── utils.go │ ├── org/ │ │ ├── org.go │ │ ├── registry/ │ │ │ ├── registry.go │ │ │ ├── registry_add.go │ │ │ ├── registry_list.go │ │ │ ├── registry_rm.go │ │ │ ├── registry_set.go │ │ │ └── registry_show.go │ │ └── secret/ │ │ ├── secret.go │ │ ├── secret_add.go │ │ ├── secret_list.go │ │ ├── secret_rm.go │ │ ├── secret_set.go │ │ └── secret_show.go │ ├── output/ │ │ ├── output.go │ │ ├── output_test.go │ │ ├── table.go │ │ └── table_test.go │ ├── pipeline/ │ │ ├── approve.go │ │ ├── create.go │ │ ├── decline.go │ │ ├── deploy/ │ │ │ └── deploy.go │ │ ├── kill.go │ │ ├── last.go │ │ ├── list.go │ │ ├── list_test.go │ │ ├── log/ │ │ │ ├── log.go │ │ │ ├── log_purge.go │ │ │ └── log_show.go │ │ ├── pipeline.go │ │ ├── pipeline_test.go │ │ ├── ps.go │ │ ├── purge.go │ │ ├── purge_test.go │ │ ├── queue.go │ │ ├── show.go │ │ ├── start.go │ │ └── stop.go │ ├── repo/ │ │ ├── cron/ │ │ │ ├── cron.go │ │ │ ├── cron_add.go │ │ │ ├── cron_list.go │ │ │ ├── cron_rm.go │ │ │ ├── cron_show.go │ │ │ └── cron_update.go │ │ ├── registry/ │ │ │ ├── registry.go │ │ │ ├── registry_add.go │ │ │ ├── registry_list.go │ │ │ ├── registry_rm.go │ │ │ ├── registry_set.go │ │ │ └── registry_show.go │ │ ├── repo.go │ │ ├── repo_add.go │ │ ├── repo_chown.go │ │ ├── repo_list.go │ │ ├── repo_repair.go │ │ ├── repo_rm.go │ │ ├── repo_show.go │ │ ├── repo_show_test.go │ │ ├── repo_sync.go │ │ ├── repo_test.go │ │ ├── repo_update.go │ │ └── secret/ │ │ ├── secret.go │ │ ├── secret_add.go │ │ ├── secret_list.go │ │ ├── secret_rm.go │ │ ├── secret_set.go │ │ └── secret_show.go │ ├── setup/ │ │ ├── setup.go │ │ ├── token_fetcher.go │ │ └── ui/ │ │ ├── ask.go │ │ └── confirm.go │ └── update/ │ ├── command.go │ ├── tar.go │ ├── types.go │ ├── updater.go │ └── updater_test.go ├── cmd/ │ ├── agent/ │ │ ├── core/ │ │ │ ├── agent.go │ │ │ ├── agent_test.go │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── flags.go │ │ │ ├── health.go │ │ │ ├── health_test.go │ │ │ └── run.go │ │ ├── dummy.go │ │ ├── main.go │ │ └── man.go │ ├── cli/ │ │ ├── app.go │ │ ├── docs.go │ │ ├── main.go │ │ └── man.go │ └── server/ │ ├── app.go │ ├── flags.go │ ├── grpc_server.go │ ├── health.go │ ├── main.go │ ├── man.go │ ├── metrics_server.go │ ├── openapi/ │ │ └── docs.go │ ├── openapi.go │ ├── openapi_json_gen.go │ ├── openapi_test.go │ ├── server.go │ └── setup.go ├── codecov.yaml ├── contrib/ │ └── woodpecker-test-repo/ │ └── .woodpecker/ │ ├── demo.yaml │ └── test.yaml ├── docker/ │ ├── Dockerfile.agent.alpine.multiarch │ ├── Dockerfile.agent.multiarch │ ├── Dockerfile.cli.alpine.multiarch.rootless │ ├── Dockerfile.cli.multiarch.rootless │ ├── Dockerfile.make │ ├── Dockerfile.server.alpine.multiarch.rootless │ └── Dockerfile.server.multiarch.rootless ├── docker-compose.example.yaml ├── docker-compose.gitpod.yaml ├── docs/ │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc.js │ ├── LICENSE │ ├── README.md │ ├── blog/ │ │ ├── 2023-06-11-hello-blog/ │ │ │ └── index.md │ │ ├── 2023-07-28-release-v1.0.0/ │ │ │ └── index.md │ │ ├── 2023-11-09-release-v2.0.0/ │ │ │ └── index.md │ │ ├── 2023-12-12-podman-image-builds/ │ │ │ └── index.md │ │ ├── 2023-12-13-debug-pipeline-steps/ │ │ │ └── index.md │ │ ├── 2023-12-15-podman-sigstore/ │ │ │ └── index.md │ │ ├── 2024-01-01-continuous-deployment/ │ │ │ └── index.md │ │ ├── 2024-05-27-release-v2.5.0/ │ │ │ └── index.md │ │ └── 2024-12-28-release-v3.0.0/ │ │ └── index.md │ ├── docs/ │ │ ├── 10-intro/ │ │ │ └── index.md │ │ ├── 20-usage/ │ │ │ ├── 10-intro.md │ │ │ ├── 100-troubleshooting.md │ │ │ ├── 15-terminology/ │ │ │ │ ├── architecture.excalidraw │ │ │ │ ├── index.md │ │ │ │ └── pipeline-workflow-step.excalidraw │ │ │ ├── 20-workflow-syntax.md │ │ │ ├── 25-workflows.md │ │ │ ├── 30-matrix-workflows.md │ │ │ ├── 40-secrets.md │ │ │ ├── 41-registries.md │ │ │ ├── 45-cron.md │ │ │ ├── 50-environment.md │ │ │ ├── 51-plugins/ │ │ │ │ ├── 20-creating-plugins.md │ │ │ │ ├── 51-overview.md │ │ │ │ └── _category_.yaml │ │ │ ├── 60-services.md │ │ │ ├── 70-volumes.md │ │ │ ├── 72-extensions/ │ │ │ │ ├── 40-configuration-extension.md │ │ │ │ ├── 50-registry-extension.md │ │ │ │ ├── 55-secret-extension.md │ │ │ │ ├── _category_.yaml │ │ │ │ └── index.md │ │ │ ├── 72-linter.md │ │ │ ├── 75-project-settings.md │ │ │ ├── 80-badges.md │ │ │ ├── 90-advanced-usage.md │ │ │ └── _category_.yaml │ │ ├── 30-administration/ │ │ │ ├── 00-general.md │ │ │ ├── 05-installation/ │ │ │ │ ├── 10-docker-compose.md │ │ │ │ ├── 20-helm-chart.md │ │ │ │ ├── 30-packages.md │ │ │ │ └── _category_.yaml │ │ │ ├── 10-configuration/ │ │ │ │ ├── 10-server.md │ │ │ │ ├── 100-addons.md │ │ │ │ ├── 11-backends/ │ │ │ │ │ ├── 10-docker.md │ │ │ │ │ ├── 20-kubernetes.md │ │ │ │ │ ├── 30-local.md │ │ │ │ │ ├── 50-custom.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 12-forges/ │ │ │ │ │ ├── 11-overview.md │ │ │ │ │ ├── 20-github.md │ │ │ │ │ ├── 30-gitea.md │ │ │ │ │ ├── 35-forgejo.md │ │ │ │ │ ├── 40-gitlab.md │ │ │ │ │ ├── 50-bitbucket.md │ │ │ │ │ ├── 60-bitbucket_datacenter.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 30-agent.md │ │ │ │ ├── 40-autoscaler.md │ │ │ │ └── _category_.yaml │ │ │ └── _category_.yaml │ │ └── 92-development/ │ │ ├── 01-getting-started.md │ │ ├── 02-core-ideas.md │ │ ├── 03-ui.md │ │ ├── 04-docs.md │ │ ├── 05-architecture.md │ │ ├── 06-conventions.md │ │ ├── 07-guides.md │ │ ├── 08-translations.md │ │ ├── 09-openapi.md │ │ ├── 09-testing.md │ │ ├── 10-packaging.md │ │ ├── 100-addons.md │ │ ├── 40-deprecations.md │ │ ├── _category_.yaml │ │ └── woodpecker-architecture.dot │ ├── docusaurus.config.ts │ ├── package.json │ ├── plugins/ │ │ └── woodpecker-plugins/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── plugins.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── markdown.ts │ │ │ ├── theme/ │ │ │ │ ├── Icons.tsx │ │ │ │ ├── WoodpeckerPlugin.tsx │ │ │ │ ├── WoodpeckerPluginList.tsx │ │ │ │ └── style.css │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ └── tsconfig.jsx.json │ ├── pnpm-workspace.yaml │ ├── sidebars.js │ ├── src/ │ │ ├── components/ │ │ │ ├── HomepageFeatures.js │ │ │ └── HomepageFeatures.module.css │ │ ├── css/ │ │ │ └── custom.css │ │ └── pages/ │ │ ├── about.md │ │ ├── awesome.md │ │ ├── index.module.css │ │ ├── index.tsx │ │ ├── migrations.md │ │ └── versions.md │ ├── tsconfig.json │ ├── versioned_docs/ │ │ ├── version-2.8/ │ │ │ ├── 10-intro/ │ │ │ │ └── index.md │ │ │ ├── 20-usage/ │ │ │ │ ├── 10-intro.md │ │ │ │ ├── 100-troubleshooting.md │ │ │ │ ├── 15-terminology/ │ │ │ │ │ ├── architecture.excalidraw │ │ │ │ │ ├── index.md │ │ │ │ │ └── pipeline-workflow-step.excalidraw │ │ │ │ ├── 20-workflow-syntax.md │ │ │ │ ├── 25-workflows.md │ │ │ │ ├── 30-matrix-workflows.md │ │ │ │ ├── 40-secrets.md │ │ │ │ ├── 41-registries.md │ │ │ │ ├── 45-cron.md │ │ │ │ ├── 50-environment.md │ │ │ │ ├── 51-plugins/ │ │ │ │ │ ├── 20-creating-plugins.md │ │ │ │ │ ├── 51-overview.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 60-services.md │ │ │ │ ├── 70-volumes.md │ │ │ │ ├── 72-linter.md │ │ │ │ ├── 75-project-settings.md │ │ │ │ ├── 80-badges.md │ │ │ │ ├── 90-advanced-usage.md │ │ │ │ └── _category_.yaml │ │ │ ├── 30-administration/ │ │ │ │ ├── 00-getting-started.md │ │ │ │ ├── 05-deployment-methods/ │ │ │ │ │ ├── 10-docker-compose.md │ │ │ │ │ ├── 20-kubernetes.md │ │ │ │ │ ├── 30-third-party.md │ │ │ │ │ ├── 40-nixos.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 10-database.md │ │ │ │ ├── 10-server-config.md │ │ │ │ ├── 11-forges/ │ │ │ │ │ ├── 100-addon.md │ │ │ │ │ ├── 11-overview.md │ │ │ │ │ ├── 20-github.md │ │ │ │ │ ├── 30-gitea.md │ │ │ │ │ ├── 35-forgejo.md │ │ │ │ │ ├── 40-gitlab.md │ │ │ │ │ ├── 50-bitbucket.md │ │ │ │ │ ├── 60-bitbucket_datacenter.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 15-agent-config.md │ │ │ │ ├── 22-backends/ │ │ │ │ │ ├── 10-docker.md │ │ │ │ │ ├── 20-local.md │ │ │ │ │ ├── 40-kubernetes.md │ │ │ │ │ ├── 50-custom-backends.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 40-advanced/ │ │ │ │ │ ├── 10-proxy.md │ │ │ │ │ ├── 100-external-configuration-api.md │ │ │ │ │ ├── 20-ssl.md │ │ │ │ │ ├── 30-autoscaler.md │ │ │ │ │ ├── 40-advanced.md │ │ │ │ │ ├── 90-prometheus.md │ │ │ │ │ └── _category_.yaml │ │ │ │ └── _category_.yaml │ │ │ ├── 40-cli.md │ │ │ ├── 50-about.md │ │ │ ├── 91-migrations.md │ │ │ ├── 92-awesome.md │ │ │ └── 92-development/ │ │ │ ├── 01-getting-started.md │ │ │ ├── 02-core-ideas.md │ │ │ ├── 03-ui.md │ │ │ ├── 04-docs.md │ │ │ ├── 05-architecture.md │ │ │ ├── 06-conventions.md │ │ │ ├── 07-guides.md │ │ │ ├── 08-translations.md │ │ │ ├── 09-swagger.md │ │ │ ├── 09-testing.md │ │ │ └── _category_.yaml │ │ ├── version-3.12/ │ │ │ ├── 10-intro/ │ │ │ │ └── index.md │ │ │ ├── 20-usage/ │ │ │ │ ├── 10-intro.md │ │ │ │ ├── 100-troubleshooting.md │ │ │ │ ├── 15-terminology/ │ │ │ │ │ ├── architecture.excalidraw │ │ │ │ │ ├── index.md │ │ │ │ │ └── pipeline-workflow-step.excalidraw │ │ │ │ ├── 20-workflow-syntax.md │ │ │ │ ├── 25-workflows.md │ │ │ │ ├── 30-matrix-workflows.md │ │ │ │ ├── 40-secrets.md │ │ │ │ ├── 41-registries.md │ │ │ │ ├── 45-cron.md │ │ │ │ ├── 50-environment.md │ │ │ │ ├── 51-plugins/ │ │ │ │ │ ├── 20-creating-plugins.md │ │ │ │ │ ├── 51-overview.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 60-services.md │ │ │ │ ├── 70-volumes.md │ │ │ │ ├── 72-extensions/ │ │ │ │ │ ├── 40-configuration-extension.md │ │ │ │ │ ├── _category_.yaml │ │ │ │ │ └── index.md │ │ │ │ ├── 72-linter.md │ │ │ │ ├── 75-project-settings.md │ │ │ │ ├── 80-badges.md │ │ │ │ ├── 90-advanced-usage.md │ │ │ │ └── _category_.yaml │ │ │ ├── 30-administration/ │ │ │ │ ├── 00-general.md │ │ │ │ ├── 05-installation/ │ │ │ │ │ ├── 10-docker-compose.md │ │ │ │ │ ├── 20-helm-chart.md │ │ │ │ │ ├── 30-packages.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 10-configuration/ │ │ │ │ │ ├── 10-server.md │ │ │ │ │ ├── 100-addons.md │ │ │ │ │ ├── 11-backends/ │ │ │ │ │ │ ├── 10-docker.md │ │ │ │ │ │ ├── 20-kubernetes.md │ │ │ │ │ │ ├── 30-local.md │ │ │ │ │ │ ├── 50-custom.md │ │ │ │ │ │ └── _category_.yaml │ │ │ │ │ ├── 12-forges/ │ │ │ │ │ │ ├── 11-overview.md │ │ │ │ │ │ ├── 20-github.md │ │ │ │ │ │ ├── 30-gitea.md │ │ │ │ │ │ ├── 35-forgejo.md │ │ │ │ │ │ ├── 40-gitlab.md │ │ │ │ │ │ ├── 50-bitbucket.md │ │ │ │ │ │ ├── 60-bitbucket_datacenter.md │ │ │ │ │ │ └── _category_.yaml │ │ │ │ │ ├── 30-agent.md │ │ │ │ │ ├── 40-autoscaler.md │ │ │ │ │ └── _category_.yaml │ │ │ │ └── _category_.yaml │ │ │ ├── 40-cli.md │ │ │ └── 92-development/ │ │ │ ├── 01-getting-started.md │ │ │ ├── 02-core-ideas.md │ │ │ ├── 03-ui.md │ │ │ ├── 04-docs.md │ │ │ ├── 05-architecture.md │ │ │ ├── 06-conventions.md │ │ │ ├── 07-guides.md │ │ │ ├── 08-translations.md │ │ │ ├── 09-openapi.md │ │ │ ├── 09-testing.md │ │ │ ├── 100-addons.md │ │ │ └── _category_.yaml │ │ ├── version-3.13/ │ │ │ ├── 10-intro/ │ │ │ │ └── index.md │ │ │ ├── 20-usage/ │ │ │ │ ├── 10-intro.md │ │ │ │ ├── 100-troubleshooting.md │ │ │ │ ├── 15-terminology/ │ │ │ │ │ ├── architecture.excalidraw │ │ │ │ │ ├── index.md │ │ │ │ │ └── pipeline-workflow-step.excalidraw │ │ │ │ ├── 20-workflow-syntax.md │ │ │ │ ├── 25-workflows.md │ │ │ │ ├── 30-matrix-workflows.md │ │ │ │ ├── 40-secrets.md │ │ │ │ ├── 41-registries.md │ │ │ │ ├── 45-cron.md │ │ │ │ ├── 50-environment.md │ │ │ │ ├── 51-plugins/ │ │ │ │ │ ├── 20-creating-plugins.md │ │ │ │ │ ├── 51-overview.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 60-services.md │ │ │ │ ├── 70-volumes.md │ │ │ │ ├── 72-extensions/ │ │ │ │ │ ├── 40-configuration-extension.md │ │ │ │ │ ├── _category_.yaml │ │ │ │ │ └── index.md │ │ │ │ ├── 72-linter.md │ │ │ │ ├── 75-project-settings.md │ │ │ │ ├── 80-badges.md │ │ │ │ ├── 90-advanced-usage.md │ │ │ │ └── _category_.yaml │ │ │ ├── 30-administration/ │ │ │ │ ├── 00-general.md │ │ │ │ ├── 05-installation/ │ │ │ │ │ ├── 10-docker-compose.md │ │ │ │ │ ├── 20-helm-chart.md │ │ │ │ │ ├── 30-packages.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 10-configuration/ │ │ │ │ │ ├── 10-server.md │ │ │ │ │ ├── 100-addons.md │ │ │ │ │ ├── 11-backends/ │ │ │ │ │ │ ├── 10-docker.md │ │ │ │ │ │ ├── 20-kubernetes.md │ │ │ │ │ │ ├── 30-local.md │ │ │ │ │ │ ├── 50-custom.md │ │ │ │ │ │ └── _category_.yaml │ │ │ │ │ ├── 12-forges/ │ │ │ │ │ │ ├── 11-overview.md │ │ │ │ │ │ ├── 20-github.md │ │ │ │ │ │ ├── 30-gitea.md │ │ │ │ │ │ ├── 35-forgejo.md │ │ │ │ │ │ ├── 40-gitlab.md │ │ │ │ │ │ ├── 50-bitbucket.md │ │ │ │ │ │ ├── 60-bitbucket_datacenter.md │ │ │ │ │ │ └── _category_.yaml │ │ │ │ │ ├── 30-agent.md │ │ │ │ │ ├── 40-autoscaler.md │ │ │ │ │ └── _category_.yaml │ │ │ │ └── _category_.yaml │ │ │ ├── 40-cli.md │ │ │ └── 92-development/ │ │ │ ├── 01-getting-started.md │ │ │ ├── 02-core-ideas.md │ │ │ ├── 03-ui.md │ │ │ ├── 04-docs.md │ │ │ ├── 05-architecture.md │ │ │ ├── 06-conventions.md │ │ │ ├── 07-guides.md │ │ │ ├── 08-translations.md │ │ │ ├── 09-openapi.md │ │ │ ├── 09-testing.md │ │ │ ├── 10-packaging.md │ │ │ ├── 100-addons.md │ │ │ └── _category_.yaml │ │ └── version-3.14/ │ │ ├── 10-intro/ │ │ │ └── index.md │ │ ├── 20-usage/ │ │ │ ├── 10-intro.md │ │ │ ├── 100-troubleshooting.md │ │ │ ├── 15-terminology/ │ │ │ │ ├── architecture.excalidraw │ │ │ │ ├── index.md │ │ │ │ └── pipeline-workflow-step.excalidraw │ │ │ ├── 20-workflow-syntax.md │ │ │ ├── 25-workflows.md │ │ │ ├── 30-matrix-workflows.md │ │ │ ├── 40-secrets.md │ │ │ ├── 41-registries.md │ │ │ ├── 45-cron.md │ │ │ ├── 50-environment.md │ │ │ ├── 51-plugins/ │ │ │ │ ├── 20-creating-plugins.md │ │ │ │ ├── 51-overview.md │ │ │ │ └── _category_.yaml │ │ │ ├── 60-services.md │ │ │ ├── 70-volumes.md │ │ │ ├── 72-extensions/ │ │ │ │ ├── 40-configuration-extension.md │ │ │ │ ├── 50-registry-extension.md │ │ │ │ ├── 55-secret-extension.md │ │ │ │ ├── _category_.yaml │ │ │ │ └── index.md │ │ │ ├── 72-linter.md │ │ │ ├── 75-project-settings.md │ │ │ ├── 80-badges.md │ │ │ ├── 90-advanced-usage.md │ │ │ └── _category_.yaml │ │ ├── 30-administration/ │ │ │ ├── 00-general.md │ │ │ ├── 05-installation/ │ │ │ │ ├── 10-docker-compose.md │ │ │ │ ├── 20-helm-chart.md │ │ │ │ ├── 30-packages.md │ │ │ │ └── _category_.yaml │ │ │ ├── 10-configuration/ │ │ │ │ ├── 10-server.md │ │ │ │ ├── 100-addons.md │ │ │ │ ├── 11-backends/ │ │ │ │ │ ├── 10-docker.md │ │ │ │ │ ├── 20-kubernetes.md │ │ │ │ │ ├── 30-local.md │ │ │ │ │ ├── 50-custom.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 12-forges/ │ │ │ │ │ ├── 11-overview.md │ │ │ │ │ ├── 20-github.md │ │ │ │ │ ├── 30-gitea.md │ │ │ │ │ ├── 35-forgejo.md │ │ │ │ │ ├── 40-gitlab.md │ │ │ │ │ ├── 50-bitbucket.md │ │ │ │ │ ├── 60-bitbucket_datacenter.md │ │ │ │ │ └── _category_.yaml │ │ │ │ ├── 30-agent.md │ │ │ │ ├── 40-autoscaler.md │ │ │ │ └── _category_.yaml │ │ │ └── _category_.yaml │ │ ├── 40-cli.md │ │ └── 92-development/ │ │ ├── 01-getting-started.md │ │ ├── 02-core-ideas.md │ │ ├── 03-ui.md │ │ ├── 04-docs.md │ │ ├── 05-architecture.md │ │ ├── 06-conventions.md │ │ ├── 07-guides.md │ │ ├── 08-translations.md │ │ ├── 09-openapi.md │ │ ├── 09-testing.md │ │ ├── 10-packaging.md │ │ ├── 100-addons.md │ │ ├── 40-deprecations.md │ │ ├── _category_.yaml │ │ └── woodpecker-architecture.dot │ ├── versioned_sidebars/ │ │ ├── version-2.8-sidebars.json │ │ ├── version-3.12-sidebars.json │ │ ├── version-3.13-sidebars.json │ │ └── version-3.14-sidebars.json │ └── versions.json ├── e2e/ │ ├── scenarios/ │ │ ├── agent_routing_test.go │ │ ├── cancel_test.go │ │ ├── fixtures/ │ │ │ ├── 01_simple_success.json │ │ │ ├── 01_simple_success.yaml │ │ │ ├── 02_step_failure.json │ │ │ ├── 02_step_failure.yaml │ │ │ ├── 03_failure_ignore.json │ │ │ ├── 03_failure_ignore.yaml │ │ │ ├── 04_on_failure_notify.json │ │ │ ├── 04_on_failure_notify.yaml │ │ │ ├── 05_service.json │ │ │ ├── 05_service.yaml │ │ │ ├── 06_parallel_steps.json │ │ │ ├── 06_parallel_steps.yaml │ │ │ ├── 07_oom_killed.json │ │ │ ├── 07_oom_killed.yaml │ │ │ ├── 08_multi_step_on_failure.json │ │ │ ├── 08_multi_step_on_failure.yaml │ │ │ ├── 09_multi_workflow_parallel/ │ │ │ │ ├── build.yaml │ │ │ │ ├── lint.yaml │ │ │ │ └── scenario.json │ │ │ ├── 10_multi_workflow_failure/ │ │ │ │ ├── failing.yaml │ │ │ │ ├── passing.yaml │ │ │ │ └── scenario.json │ │ │ ├── 11_multi_workflow_failure_ignore/ │ │ │ │ ├── flaky.yaml │ │ │ │ ├── main.yaml │ │ │ │ └── scenario.json │ │ │ ├── 12_multi_workflow_depends_on/ │ │ │ │ ├── build.yaml │ │ │ │ ├── deploy.yaml │ │ │ │ ├── notify.yaml │ │ │ │ └── scenario.json │ │ │ └── 13_multi_workflow_depends_on_failure/ │ │ │ ├── build.yaml │ │ │ ├── deploy.yaml │ │ │ └── scenario.json │ │ ├── fixtures.go │ │ ├── infra_test.go │ │ ├── matrix_test.go │ │ ├── restart_test.go │ │ └── suite_test.go │ └── setup/ │ ├── agent.go │ ├── forge.go │ ├── server.go │ ├── store.go │ └── wait.go ├── flake.nix ├── go.mod ├── go.sum ├── nfpm/ │ ├── agent.yaml │ ├── cli.yaml │ ├── server.yaml │ ├── woodpecker-agent.env.example │ ├── woodpecker-agent.service │ ├── woodpecker-server.env.example │ ├── woodpecker-server.service │ └── woodpecker-system-user.preinstall.sh ├── pipeline/ │ ├── backend/ │ │ ├── backend.go │ │ ├── common/ │ │ │ ├── script.go │ │ │ ├── script_posix.go │ │ │ ├── script_posix_test.go │ │ │ ├── script_test.go │ │ │ ├── script_win.go │ │ │ └── script_win_test.go │ │ ├── docker/ │ │ │ ├── backend_options.go │ │ │ ├── backend_options_test.go │ │ │ ├── config.go │ │ │ ├── convert.go │ │ │ ├── convert_test.go │ │ │ ├── convert_win.go │ │ │ ├── convert_win_test.go │ │ │ ├── docker.go │ │ │ ├── errors.go │ │ │ └── flags.go │ │ ├── dummy/ │ │ │ ├── dummy.go │ │ │ └── dummy_test.go │ │ ├── kubernetes/ │ │ │ ├── backend_options.go │ │ │ ├── backend_options_test.go │ │ │ ├── flags.go │ │ │ ├── kubernetes.go │ │ │ ├── kubernetes_test.go │ │ │ ├── namespace.go │ │ │ ├── namespace_test.go │ │ │ ├── pod.go │ │ │ ├── pod_test.go │ │ │ ├── secrets.go │ │ │ ├── secrets_test.go │ │ │ ├── service.go │ │ │ ├── service_test.go │ │ │ ├── utils.go │ │ │ ├── utils_test.go │ │ │ ├── volume.go │ │ │ └── volume_test.go │ │ ├── local/ │ │ │ ├── clone.go │ │ │ ├── command.go │ │ │ ├── command_test.go │ │ │ ├── const.go │ │ │ ├── const_test.go │ │ │ ├── errors.go │ │ │ ├── flags.go │ │ │ ├── local.go │ │ │ ├── local_test.go │ │ │ └── plugin.go │ │ └── types/ │ │ ├── auth.go │ │ ├── backend.go │ │ ├── config.go │ │ ├── conn.go │ │ ├── errors.go │ │ ├── mocks/ │ │ │ └── mock_Backend.go │ │ ├── network.go │ │ ├── secret.go │ │ ├── stage.go │ │ ├── state.go │ │ └── step.go │ ├── const.go │ ├── errors/ │ │ ├── linter.go │ │ ├── linter_test.go │ │ ├── pipeline.go │ │ └── runtime.go │ ├── frontend/ │ │ ├── metadata/ │ │ │ ├── const.go │ │ │ ├── drone_compatibility.go │ │ │ ├── drone_compatibility_test.go │ │ │ ├── environment.go │ │ │ ├── environment_test.go │ │ │ ├── substitution.go │ │ │ ├── substitution_test.go │ │ │ └── types.go │ │ └── yaml/ │ │ ├── compiler/ │ │ │ ├── compiler.go │ │ │ ├── compiler_test.go │ │ │ ├── convert.go │ │ │ ├── convert_test.go │ │ │ ├── dag.go │ │ │ ├── dag_test.go │ │ │ ├── errors.go │ │ │ ├── option.go │ │ │ ├── option_test.go │ │ │ └── settings/ │ │ │ ├── params.go │ │ │ └── params_test.go │ │ ├── constraint/ │ │ │ ├── constraint.go │ │ │ ├── constraint_test.go │ │ │ ├── list.go │ │ │ ├── list_test.go │ │ │ ├── map.go │ │ │ ├── map_test.go │ │ │ ├── path.go │ │ │ ├── path_test.go │ │ │ └── skip.go │ │ ├── linter/ │ │ │ ├── error.go │ │ │ ├── linter.go │ │ │ ├── linter_test.go │ │ │ ├── option.go │ │ │ └── schema/ │ │ │ ├── .woodpecker/ │ │ │ │ ├── test-array-syntax.yaml │ │ │ │ ├── test-backend-options.yaml │ │ │ │ ├── test-broken-plugin.yaml │ │ │ │ ├── test-broken-plugin2.yaml │ │ │ │ ├── test-broken.yaml │ │ │ │ ├── test-clone-skip.yaml │ │ │ │ ├── test-clone.yaml │ │ │ │ ├── test-custom-backend.yaml │ │ │ │ ├── test-dag.yaml │ │ │ │ ├── test-kubernetes-backend-tolerations.yaml │ │ │ │ ├── test-labels.yaml │ │ │ │ ├── test-matrix.yaml │ │ │ │ ├── test-merge-map-and-sequence.yaml │ │ │ │ ├── test-multi.yaml │ │ │ │ ├── test-pipeline-when.yaml │ │ │ │ ├── test-plugin.yaml │ │ │ │ ├── test-run-on.yaml │ │ │ │ ├── test-service.yaml │ │ │ │ ├── test-step.yaml │ │ │ │ ├── test-when.yaml │ │ │ │ └── test-workspace.yaml │ │ │ ├── schema.go │ │ │ ├── schema.json │ │ │ └── schema_test.go │ │ ├── matrix/ │ │ │ ├── matrix.go │ │ │ └── matrix_test.go │ │ ├── parse.go │ │ ├── parse_test.go │ │ ├── types/ │ │ │ ├── base/ │ │ │ │ ├── int.go │ │ │ │ ├── int_test.go │ │ │ │ ├── slice.go │ │ │ │ └── slice_test.go │ │ │ ├── container.go │ │ │ ├── container_list.go │ │ │ ├── container_test.go │ │ │ ├── network.go │ │ │ ├── network_test.go │ │ │ ├── volume.go │ │ │ ├── volume_test.go │ │ │ └── workflow.go │ │ └── utils/ │ │ ├── image.go │ │ └── image_test.go │ ├── logging/ │ │ └── logger.go │ ├── runtime/ │ │ ├── helpers_test.go │ │ ├── option.go │ │ ├── runtime.go │ │ ├── runtime_test.go │ │ ├── shutdown.go │ │ ├── step.go │ │ ├── step_test.go │ │ ├── workflow.go │ │ └── workflow_test.go │ ├── shared/ │ │ ├── replace_secrets.go │ │ └── replace_secrets_test.go │ ├── state/ │ │ └── state.go │ ├── tracing/ │ │ ├── mocks/ │ │ │ └── mock_Tracer.go │ │ └── tracer.go │ └── utils/ │ ├── copy_line_by_line.go │ └── copy_line_by_line_test.go ├── release-config.ts ├── rpc/ │ ├── log_entry.go │ ├── log_entry_test.go │ ├── mocks/ │ │ └── mock_Peer.go │ ├── peer.go │ ├── proto/ │ │ ├── generate.go │ │ ├── version.go │ │ ├── woodpecker.pb.go │ │ ├── woodpecker.proto │ │ └── woodpecker_grpc.pb.go │ └── types.go ├── server/ │ ├── api/ │ │ ├── agent.go │ │ ├── agent_test.go │ │ ├── badge.go │ │ ├── cron.go │ │ ├── debug/ │ │ │ └── debug.go │ │ ├── forge.go │ │ ├── global_registry.go │ │ ├── global_secret.go │ │ ├── helper.go │ │ ├── helper_test.go │ │ ├── hook.go │ │ ├── hook_test.go │ │ ├── login.go │ │ ├── login_test.go │ │ ├── metrics/ │ │ │ └── prometheus.go │ │ ├── org.go │ │ ├── org_registry.go │ │ ├── org_secret.go │ │ ├── pipeline.go │ │ ├── pipeline_test.go │ │ ├── queue.go │ │ ├── registry.go │ │ ├── repo.go │ │ ├── repo_secret.go │ │ ├── repo_test.go │ │ ├── signature_public_key.go │ │ ├── stream.go │ │ ├── stream_test.go │ │ ├── user.go │ │ ├── users.go │ │ └── z.go │ ├── badges/ │ │ ├── badges.go │ │ ├── badges_test.go │ │ ├── color.go │ │ ├── drawer.go │ │ ├── fonts/ │ │ │ └── dejavusans.go │ │ └── style.go │ ├── cache/ │ │ └── membership.go │ ├── ccmenu/ │ │ ├── cc.go │ │ └── cc_test.go │ ├── config.go │ ├── cron/ │ │ ├── cron.go │ │ └── cron_test.go │ ├── forge/ │ │ ├── addon/ │ │ │ ├── args.go │ │ │ ├── client.go │ │ │ ├── plugin.go │ │ │ └── server.go │ │ ├── bitbucket/ │ │ │ ├── bitbucket.go │ │ │ ├── bitbucket_test.go │ │ │ ├── convert.go │ │ │ ├── convert_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── HookPull.json │ │ │ │ ├── HookPullRequestDeclined.json │ │ │ │ ├── HookPullRequestMerged.json │ │ │ │ ├── HookPush.json │ │ │ │ ├── handler.go │ │ │ │ └── hooks.go │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ └── types.go │ │ │ ├── parse.go │ │ │ └── parse_test.go │ │ ├── bitbucketdatacenter/ │ │ │ ├── bitbucketdatacenter.go │ │ │ ├── bitbucketdatacenter_test.go │ │ │ ├── convert.go │ │ │ ├── convert_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── HookPullRequestMerged.json │ │ │ │ ├── HookPullRequestOpened.json │ │ │ │ ├── HookPullRequestOpenedFromFork.json │ │ │ │ ├── HookPush.json │ │ │ │ ├── expected/ │ │ │ │ │ └── PostBuildStatus.json │ │ │ │ ├── handler.go │ │ │ │ └── hooks.go │ │ │ ├── internal/ │ │ │ │ ├── client.go │ │ │ │ └── client_test.go │ │ │ ├── parse.go │ │ │ └── parse_test.go │ │ ├── common/ │ │ │ ├── event_normalize.go │ │ │ ├── status.go │ │ │ ├── status_test.go │ │ │ ├── utils.go │ │ │ └── utils_test.go │ │ ├── forge.go │ │ ├── forgejo/ │ │ │ ├── fixtures/ │ │ │ │ ├── HookPullRequest.json │ │ │ │ ├── HookPullRequestAssigneeCleared.json │ │ │ │ ├── HookPullRequestAssigneesAdded.json │ │ │ │ ├── HookPullRequestClosed.json │ │ │ │ ├── HookPullRequestEdited.json │ │ │ │ ├── HookPullRequestLabelAdded.json │ │ │ │ ├── HookPullRequestLabelsCleared.json │ │ │ │ ├── HookPullRequestLabelsUpdated.json │ │ │ │ ├── HookPullRequestMerged.json │ │ │ │ ├── HookPullRequestMilestoneAdded.json │ │ │ │ ├── HookPullRequestMilestoneChanged.json │ │ │ │ ├── HookPullRequestMilestoneCleared.json │ │ │ │ ├── HookPullRequestReopened.json │ │ │ │ ├── HookPullRequestUpdated.json │ │ │ │ ├── HookPush.json │ │ │ │ ├── HookPushBranch.json │ │ │ │ ├── HookPushMulti.json │ │ │ │ ├── HookRelease.json │ │ │ │ ├── HookTag.json │ │ │ │ ├── handler.go │ │ │ │ └── hooks.go │ │ │ ├── forgejo.go │ │ │ ├── forgejo_test.go │ │ │ ├── helper.go │ │ │ ├── helper_test.go │ │ │ ├── parse.go │ │ │ ├── parse_test.go │ │ │ └── types.go │ │ ├── gitea/ │ │ │ ├── fixtures/ │ │ │ │ ├── HookPullRequest.json │ │ │ │ ├── HookPullRequestAddLabel.json │ │ │ │ ├── HookPullRequestAddMile.json │ │ │ │ ├── HookPullRequestAddReviewRequest.json │ │ │ │ ├── HookPullRequestAssigneesAdded.json │ │ │ │ ├── HookPullRequestAssigneesRemoved.json │ │ │ │ ├── HookPullRequestChangeBody.json │ │ │ │ ├── HookPullRequestChangeLabel.json │ │ │ │ ├── HookPullRequestChangeMile.json │ │ │ │ ├── HookPullRequestChangeTitle.json │ │ │ │ ├── HookPullRequestClosed.json │ │ │ │ ├── HookPullRequestMerged.json │ │ │ │ ├── HookPullRequestRemoveLabel.json │ │ │ │ ├── HookPullRequestRemoveMile.json │ │ │ │ ├── HookPullRequestReopened.json │ │ │ │ ├── HookPullRequestReviewAck.json │ │ │ │ ├── HookPullRequestReviewComment.json │ │ │ │ ├── HookPullRequestReviewDeny.json │ │ │ │ ├── HookPullRequestUpdated.json │ │ │ │ ├── HookPush.json │ │ │ │ ├── HookPushBranch.json │ │ │ │ ├── HookPushMulti.json │ │ │ │ ├── HookRelease.json │ │ │ │ ├── HookTag.json │ │ │ │ ├── handler.go │ │ │ │ └── hooks.go │ │ │ ├── gitea.go │ │ │ ├── gitea_test.go │ │ │ ├── helper.go │ │ │ ├── helper_test.go │ │ │ ├── parse.go │ │ │ ├── parse_test.go │ │ │ └── types.go │ │ ├── github/ │ │ │ ├── convert.go │ │ │ ├── convert_test.go │ │ │ ├── fixtures/ │ │ │ │ ├── HookDeploy.json │ │ │ │ ├── HookPullRequest.json │ │ │ │ ├── HookPullRequestAssigneeAdded.json │ │ │ │ ├── HookPullRequestAssigneeRemoved.json │ │ │ │ ├── HookPullRequestClosed.json │ │ │ │ ├── HookPullRequestEdited.json │ │ │ │ ├── HookPullRequestLabelAdded.json │ │ │ │ ├── HookPullRequestLabelRemoved.json │ │ │ │ ├── HookPullRequestLabelsCleared.json │ │ │ │ ├── HookPullRequestMerged.json │ │ │ │ ├── HookPullRequestMilestoneAdded.json │ │ │ │ ├── HookPullRequestMilestoneRemoved.json │ │ │ │ ├── HookPullRequestReopened.json │ │ │ │ ├── HookPullRequestReviewRequested.json │ │ │ │ ├── HookPush.json │ │ │ │ ├── HookRelease.json │ │ │ │ ├── HookTag.json │ │ │ │ ├── handler.go │ │ │ │ ├── hooks.go │ │ │ │ └── mock_server.go │ │ │ ├── github.go │ │ │ ├── github_test.go │ │ │ ├── parse.go │ │ │ └── parse_test.go │ │ ├── gitlab/ │ │ │ ├── convert.go │ │ │ ├── fixtures/ │ │ │ │ ├── HookPullRequestApproved.json │ │ │ │ ├── HookPullRequestAssigned.json │ │ │ │ ├── HookPullRequestClosed.json │ │ │ │ ├── HookPullRequestDemilestoned.json │ │ │ │ ├── HookPullRequestEdited.json │ │ │ │ ├── HookPullRequestLabelsAdded.json │ │ │ │ ├── HookPullRequestLabelsCleared.json │ │ │ │ ├── HookPullRequestLabelsUpdated.json │ │ │ │ ├── HookPullRequestMerged.json │ │ │ │ ├── HookPullRequestMilestoned.json │ │ │ │ ├── HookPullRequestOpened.json │ │ │ │ ├── HookPullRequestReopened.json │ │ │ │ ├── HookPullRequestReviewRequestDel.json │ │ │ │ ├── HookPullRequestReviewRequested.json │ │ │ │ ├── HookPullRequestUnapproved.json │ │ │ │ ├── HookPullRequestUnassigned.json │ │ │ │ ├── HookPullRequestUnsupportedAction.json │ │ │ │ ├── HookPullRequestUpdated.json │ │ │ │ ├── HookPullRequestWithoutChanges.json │ │ │ │ ├── HookPush.json │ │ │ │ ├── HookTag.json │ │ │ │ ├── WebhookReleaseBody.json │ │ │ │ ├── hooks.go │ │ │ │ ├── oauth.go │ │ │ │ ├── projects.go │ │ │ │ ├── testdata.go │ │ │ │ └── users.go │ │ │ ├── gitlab.go │ │ │ ├── gitlab_test.go │ │ │ ├── helper.go │ │ │ └── status.go │ │ ├── mocks/ │ │ │ ├── mock_Forge.go │ │ │ └── mock_Refresher.go │ │ ├── refresh.go │ │ ├── refresh_test.go │ │ ├── setup/ │ │ │ └── setup.go │ │ └── types/ │ │ ├── errors.go │ │ ├── meta.go │ │ ├── meta_test.go │ │ └── oauth.go │ ├── logging/ │ │ ├── LICENSE │ │ ├── log.go │ │ ├── log_test.go │ │ └── logging.go │ ├── model/ │ │ ├── agent.go │ │ ├── agent_test.go │ │ ├── commit.go │ │ ├── config.go │ │ ├── const.go │ │ ├── cron.go │ │ ├── environ.go │ │ ├── event.go │ │ ├── feed.go │ │ ├── forge.go │ │ ├── log.go │ │ ├── netrc.go │ │ ├── org.go │ │ ├── pagination.go │ │ ├── pagination_test.go │ │ ├── perm.go │ │ ├── pipeline.go │ │ ├── pull_request.go │ │ ├── queue.go │ │ ├── redirection.go │ │ ├── registry.go │ │ ├── repo.go │ │ ├── repo_test.go │ │ ├── secret.go │ │ ├── secret_test.go │ │ ├── server_config.go │ │ ├── step.go │ │ ├── step_test.go │ │ ├── task.go │ │ ├── task_test.go │ │ ├── team.go │ │ ├── user.go │ │ ├── user_test.go │ │ └── workflow.go │ ├── pipeline/ │ │ ├── approve.go │ │ ├── cancel.go │ │ ├── config.go │ │ ├── create.go │ │ ├── decline.go │ │ ├── errors.go │ │ ├── gated.go │ │ ├── gated_test.go │ │ ├── helper.go │ │ ├── items.go │ │ ├── items_test.go │ │ ├── pipeline_status.go │ │ ├── pipeline_status_test.go │ │ ├── queue.go │ │ ├── restart.go │ │ ├── start.go │ │ ├── status.go │ │ ├── status_test.go │ │ ├── step_builder/ │ │ │ ├── metadata.go │ │ │ ├── metadata_test.go │ │ │ ├── step_builder.go │ │ │ └── step_builder_test.go │ │ ├── step_status.go │ │ ├── step_status_test.go │ │ ├── topic.go │ │ ├── workflow_status.go │ │ └── workflow_status_test.go │ ├── pubsub/ │ │ ├── memory/ │ │ │ ├── pub.go │ │ │ └── pub_test.go │ │ ├── pubsub.go │ │ └── pubsub_test.go │ ├── queue/ │ │ ├── fifo.go │ │ ├── fifo_test.go │ │ ├── mocks/ │ │ │ └── mock_Queue.go │ │ ├── persistent.go │ │ └── queue.go │ ├── router/ │ │ ├── api.go │ │ ├── middleware/ │ │ │ ├── header/ │ │ │ │ └── header.go │ │ │ ├── logger.go │ │ │ ├── session/ │ │ │ │ ├── agent.go │ │ │ │ ├── org.go │ │ │ │ ├── pagination.go │ │ │ │ ├── repo.go │ │ │ │ └── user.go │ │ │ ├── store.go │ │ │ ├── token/ │ │ │ │ └── token.go │ │ │ └── version.go │ │ └── router.go │ ├── rpc/ │ │ ├── auth_server.go │ │ ├── auth_server_test.go │ │ ├── authorizer.go │ │ ├── authorizer_test.go │ │ ├── errors.go │ │ ├── filter.go │ │ ├── filter_test.go │ │ ├── jwt_manager.go │ │ ├── jwt_manager_test.go │ │ ├── rpc.go │ │ ├── rpc_integration_test.go │ │ ├── rpc_test.go │ │ ├── sanitize.go │ │ ├── sanitize_test.go │ │ └── server.go │ ├── scheduler/ │ │ ├── proxy.go │ │ └── scheduler.go │ ├── services/ │ │ ├── config/ │ │ │ ├── combined.go │ │ │ ├── combined_test.go │ │ │ ├── forge.go │ │ │ ├── forge_test.go │ │ │ ├── http.go │ │ │ ├── mocks/ │ │ │ │ └── mock_Service.go │ │ │ └── service.go │ │ ├── encryption/ │ │ │ ├── aes.go │ │ │ ├── aes_builder.go │ │ │ ├── aes_encryption.go │ │ │ ├── aes_state.go │ │ │ ├── aes_test.go │ │ │ ├── constants.go │ │ │ ├── encryption.go │ │ │ ├── encryption_builder.go │ │ │ ├── no_encryption.go │ │ │ ├── tink.go │ │ │ ├── tink_builder.go │ │ │ ├── tink_keyset.go │ │ │ ├── tink_keyset_watcher.go │ │ │ ├── tink_state.go │ │ │ ├── types/ │ │ │ │ └── encryption.go │ │ │ └── wrapper/ │ │ │ └── store/ │ │ │ ├── constants.go │ │ │ ├── secret_store.go │ │ │ └── secret_store_wrapper.go │ │ ├── environment/ │ │ │ ├── mocks/ │ │ │ │ └── mock_Service.go │ │ │ ├── parse.go │ │ │ ├── parse_test.go │ │ │ └── service.go │ │ ├── log/ │ │ │ ├── addon/ │ │ │ │ ├── client.go │ │ │ │ ├── plugin.go │ │ │ │ └── server.go │ │ │ ├── file/ │ │ │ │ └── file.go │ │ │ ├── mocks/ │ │ │ │ └── mock_Service.go │ │ │ └── service.go │ │ ├── manager.go │ │ ├── mocks/ │ │ │ └── mock_Manager.go │ │ ├── permissions/ │ │ │ ├── admins.go │ │ │ ├── admins_test.go │ │ │ ├── orgs.go │ │ │ ├── orgs_test.go │ │ │ ├── repo_owners.go │ │ │ └── repo_owners_test.go │ │ ├── registry/ │ │ │ ├── combined.go │ │ │ ├── combined_test.go │ │ │ ├── db.go │ │ │ ├── filesystem.go │ │ │ ├── http.go │ │ │ ├── mocks/ │ │ │ │ ├── mock_ReadOnlyService.go │ │ │ │ └── mock_Service.go │ │ │ ├── service.go │ │ │ ├── with_extension.go │ │ │ └── with_extension_test.go │ │ ├── secret/ │ │ │ ├── combined.go │ │ │ ├── combined_test.go │ │ │ ├── db.go │ │ │ ├── db_test.go │ │ │ ├── http.go │ │ │ ├── mocks/ │ │ │ │ └── mock_Service.go │ │ │ └── service.go │ │ ├── setup.go │ │ └── utils/ │ │ ├── hostmatcher/ │ │ │ ├── hostmatcher.go │ │ │ ├── hostmatcher_test.go │ │ │ └── http.go │ │ ├── http.go │ │ └── http_test.go │ ├── store/ │ │ ├── common.go │ │ ├── context.go │ │ ├── datastore/ │ │ │ ├── agent.go │ │ │ ├── agent_test.go │ │ │ ├── config.go │ │ │ ├── config_test.go │ │ │ ├── cron.go │ │ │ ├── cron_test.go │ │ │ ├── engine.go │ │ │ ├── engine_test.go │ │ │ ├── errors.go │ │ │ ├── feed.go │ │ │ ├── feed_test.go │ │ │ ├── forge.go │ │ │ ├── forge_test.go │ │ │ ├── helper.go │ │ │ ├── helper_test.go │ │ │ ├── init.go │ │ │ ├── init_cgo.go │ │ │ ├── log.go │ │ │ ├── log_test.go │ │ │ ├── migration/ │ │ │ │ ├── 000_legacy_to_xormigrate.go │ │ │ │ ├── 001_add_org_id.go │ │ │ │ ├── 002_task_data_type.go │ │ │ │ ├── 003_config_data_type.go │ │ │ │ ├── 004_remove_secrets_plugin_only_col.go │ │ │ │ ├── 005_convert_to_new_pipeline_errors_format.go │ │ │ │ ├── 006_link_to_url.go │ │ │ │ ├── 007_clean_registry_pipeline.go │ │ │ │ ├── 008_set_default_forge_id.go │ │ │ │ ├── 009_unify_columns_tables.go │ │ │ │ ├── 010_registries_add_user.go │ │ │ │ ├── 011_cron_without_sec.go │ │ │ │ ├── 012_rename_start_end_time.go │ │ │ │ ├── 013_fix_v31_registries.go │ │ │ │ ├── 014_remove_old_migrations_of_v1.go │ │ │ │ ├── 015_add_org_agents.go │ │ │ │ ├── 016_add_custom_labels_to_agent.go │ │ │ │ ├── 017_split_trusted.go │ │ │ │ ├── 018_fix_orgs_users_match.go │ │ │ │ ├── 019_gated_to_require_approval.go │ │ │ │ ├── 020_remove_repo_netrc_only_trusted.go │ │ │ │ ├── 021_rename_token_fields.go │ │ │ │ ├── 022_set_new_defaults_for_require_approval.go │ │ │ │ ├── 023_remove_repo_scm.go │ │ │ │ ├── 024_unsanitize_org_and_user_names.go │ │ │ │ ├── 025_fix_zero_forge_id_ref.go │ │ │ │ ├── 026_fix_forge_columns.go │ │ │ │ ├── 027_add_cron_field.go │ │ │ │ ├── common.go │ │ │ │ ├── common_test.go │ │ │ │ ├── logger.go │ │ │ │ ├── migration.go │ │ │ │ ├── migration_test.go │ │ │ │ └── test-files/ │ │ │ │ ├── .gitignore │ │ │ │ └── postgres.sql │ │ │ ├── org.go │ │ │ ├── org_test.go │ │ │ ├── permission.go │ │ │ ├── permission_test.go │ │ │ ├── pipeline.go │ │ │ ├── pipeline_test.go │ │ │ ├── redirection.go │ │ │ ├── redirection_test.go │ │ │ ├── registry.go │ │ │ ├── registry_test.go │ │ │ ├── repo.go │ │ │ ├── repo_test.go │ │ │ ├── secret.go │ │ │ ├── secret_test.go │ │ │ ├── server_config.go │ │ │ ├── server_config_test.go │ │ │ ├── step.go │ │ │ ├── step_test.go │ │ │ ├── task.go │ │ │ ├── task_test.go │ │ │ ├── user.go │ │ │ ├── user_test.go │ │ │ ├── workflow.go │ │ │ ├── workflow_test.go │ │ │ └── xorm.go │ │ ├── mocks/ │ │ │ └── mock_Store.go │ │ ├── store.go │ │ └── types/ │ │ └── errors.go │ └── web/ │ ├── config.go │ ├── web.go │ └── web_test.go ├── shared/ │ ├── constant/ │ │ └── constant.go │ ├── httputil/ │ │ ├── http_error.go │ │ ├── http_error_test.go │ │ ├── httputil.go │ │ ├── useragent.go │ │ └── useragent_test.go │ ├── logger/ │ │ ├── addon_logger.go │ │ ├── logger.go │ │ └── terminal.go │ ├── optional/ │ │ ├── option.go │ │ ├── option_test.go │ │ ├── serialization.go │ │ ├── serialization_json_test.go │ │ ├── serialization_test.go │ │ └── serialization_yaml_test.go │ ├── token/ │ │ ├── token.go │ │ └── token_test.go │ └── utils/ │ ├── context.go │ ├── paginate.go │ ├── paginate_test.go │ ├── protected.go │ ├── slices.go │ ├── slices_test.go │ ├── strings.go │ └── strings_test.go ├── tools/ │ └── tools.go ├── version/ │ └── version.go ├── web/ │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc.js │ ├── .yamlignore │ ├── LICENSE │ ├── components.d.ts │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── assets/ │ │ │ └── locales/ │ │ │ ├── bar.json │ │ │ ├── cs.json │ │ │ ├── de.json │ │ │ ├── en.json │ │ │ ├── eo.json │ │ │ ├── es.json │ │ │ ├── fi.json │ │ │ ├── fr.json │ │ │ ├── hu.json │ │ │ ├── id.json │ │ │ ├── it.json │ │ │ ├── lv.json │ │ │ ├── nb-NO.json │ │ │ ├── nl.json │ │ │ ├── pl.json │ │ │ ├── pt.json │ │ │ ├── ru.json │ │ │ ├── uk.json │ │ │ ├── zh-Hans.json │ │ │ └── zh-Hant.json │ │ ├── components/ │ │ │ ├── FileTree.vue │ │ │ ├── admin/ │ │ │ │ └── settings/ │ │ │ │ ├── forges/ │ │ │ │ │ └── AdminForgeForm.vue │ │ │ │ └── queue/ │ │ │ │ └── AdminQueueStats.vue │ │ │ ├── agent/ │ │ │ │ ├── AgentForm.vue │ │ │ │ ├── AgentList.vue │ │ │ │ └── AgentManager.vue │ │ │ ├── atomic/ │ │ │ │ ├── Badge.vue │ │ │ │ ├── Button.vue │ │ │ │ ├── CountBadge.vue │ │ │ │ ├── DocsLink.vue │ │ │ │ ├── Error.vue │ │ │ │ ├── Icon.vue │ │ │ │ ├── IconButton.vue │ │ │ │ ├── ListItem.vue │ │ │ │ ├── RenderMarkdown.vue │ │ │ │ ├── SvgIcon.vue │ │ │ │ ├── SyntaxHighlight.ts │ │ │ │ └── Warning.vue │ │ │ ├── form/ │ │ │ │ ├── Checkbox.vue │ │ │ │ ├── CheckboxesField.vue │ │ │ │ ├── InputField.vue │ │ │ │ ├── KeyValueEditor.vue │ │ │ │ ├── NumberField.vue │ │ │ │ ├── RadioField.vue │ │ │ │ ├── SelectField.vue │ │ │ │ ├── TextField.vue │ │ │ │ └── form.types.ts │ │ │ ├── layout/ │ │ │ │ ├── Container.vue │ │ │ │ ├── Panel.vue │ │ │ │ ├── Popup.vue │ │ │ │ ├── Settings.vue │ │ │ │ ├── header/ │ │ │ │ │ ├── ActivePipelines.vue │ │ │ │ │ └── Navbar.vue │ │ │ │ ├── popups/ │ │ │ │ │ └── DeployPipelinePopup.vue │ │ │ │ └── scaffold/ │ │ │ │ ├── Header.vue │ │ │ │ ├── Scaffold.vue │ │ │ │ ├── Tab.vue │ │ │ │ └── Tabs.vue │ │ │ ├── pipeline-feed/ │ │ │ │ ├── PipelineFeedItem.vue │ │ │ │ └── PipelineFeedSidebar.vue │ │ │ ├── registry/ │ │ │ │ ├── RegistryEdit.vue │ │ │ │ └── RegistryList.vue │ │ │ ├── repo/ │ │ │ │ ├── RepoItem.vue │ │ │ │ └── pipeline/ │ │ │ │ ├── PipelineItem.vue │ │ │ │ ├── PipelineList.vue │ │ │ │ ├── PipelineLog.vue │ │ │ │ ├── PipelineRunningIcon.vue │ │ │ │ ├── PipelineStatusIcon.vue │ │ │ │ ├── PipelineStepDuration.vue │ │ │ │ ├── PipelineStepList.vue │ │ │ │ └── pipeline-status.ts │ │ │ └── secrets/ │ │ │ ├── SecretEdit.vue │ │ │ └── SecretList.vue │ │ ├── compositions/ │ │ │ ├── useApiClient.ts │ │ │ ├── useAsyncAction.ts │ │ │ ├── useAuthentication.ts │ │ │ ├── useConfig.ts │ │ │ ├── useDate.ts │ │ │ ├── useElapsedTime.ts │ │ │ ├── useEvents.ts │ │ │ ├── useFavicon.ts │ │ │ ├── useForgeStore.ts │ │ │ ├── useI18n.ts │ │ │ ├── useInjectProvide.ts │ │ │ ├── useInterval.ts │ │ │ ├── useNotifications.ts │ │ │ ├── usePaginate.test.ts │ │ │ ├── usePaginate.ts │ │ │ ├── usePipeline.ts │ │ │ ├── usePipelineFeed.ts │ │ │ ├── useRepoSearch.ts │ │ │ ├── useRepos.ts │ │ │ ├── useRouteBack.ts │ │ │ ├── useTabs.ts │ │ │ ├── useTheme.ts │ │ │ ├── useUserConfig.ts │ │ │ ├── useVersion.ts │ │ │ └── useWPTitle.ts │ │ ├── lib/ │ │ │ ├── api/ │ │ │ │ ├── client.ts │ │ │ │ ├── index.ts │ │ │ │ └── types/ │ │ │ │ ├── agent.ts │ │ │ │ ├── cron.ts │ │ │ │ ├── forge.ts │ │ │ │ ├── index.ts │ │ │ │ ├── org.ts │ │ │ │ ├── pipeline.ts │ │ │ │ ├── pipelineConfig.ts │ │ │ │ ├── pull_request.ts │ │ │ │ ├── queue.ts │ │ │ │ ├── registry.ts │ │ │ │ ├── repo.ts │ │ │ │ ├── secret.ts │ │ │ │ ├── user.ts │ │ │ │ └── webhook.ts │ │ │ ├── utils/ │ │ │ │ └── index.ts │ │ │ └── utils.test.ts │ │ ├── main.ts │ │ ├── router.ts │ │ ├── store/ │ │ │ ├── pipelines.ts │ │ │ └── repos.ts │ │ ├── style/ │ │ │ ├── console.css │ │ │ └── prism.css │ │ ├── style.css │ │ ├── tailwind.css │ │ ├── views/ │ │ │ ├── Login.vue │ │ │ ├── NotFound.vue │ │ │ ├── RepoAdd.vue │ │ │ ├── Repos.vue │ │ │ ├── RouterView.vue │ │ │ ├── admin/ │ │ │ │ ├── AdminAgents.vue │ │ │ │ ├── AdminInfo.vue │ │ │ │ ├── AdminOrgs.vue │ │ │ │ ├── AdminQueue.vue │ │ │ │ ├── AdminRegistries.vue │ │ │ │ ├── AdminRepos.vue │ │ │ │ ├── AdminSecrets.vue │ │ │ │ ├── AdminSettingsWrapper.vue │ │ │ │ ├── AdminUsers.vue │ │ │ │ └── forges/ │ │ │ │ ├── AdminForge.vue │ │ │ │ ├── AdminForgeCreate.vue │ │ │ │ └── AdminForges.vue │ │ │ ├── cli/ │ │ │ │ └── Auth.vue │ │ │ ├── org/ │ │ │ │ ├── OrgDeprecatedRedirect.vue │ │ │ │ ├── OrgRepos.vue │ │ │ │ ├── OrgWrapper.vue │ │ │ │ └── settings/ │ │ │ │ ├── OrgAgents.vue │ │ │ │ ├── OrgRegistries.vue │ │ │ │ ├── OrgSecrets.vue │ │ │ │ └── OrgSettingsWrapper.vue │ │ │ ├── repo/ │ │ │ │ ├── RepoBranch.vue │ │ │ │ ├── RepoBranches.vue │ │ │ │ ├── RepoDeprecatedRedirect.vue │ │ │ │ ├── RepoManualPipeline.vue │ │ │ │ ├── RepoPipelines.vue │ │ │ │ ├── RepoPullRequest.vue │ │ │ │ ├── RepoPullRequests.vue │ │ │ │ ├── RepoWrapper.vue │ │ │ │ ├── pipeline/ │ │ │ │ │ ├── Pipeline.vue │ │ │ │ │ ├── PipelineChangedFiles.vue │ │ │ │ │ ├── PipelineConfig.vue │ │ │ │ │ ├── PipelineDebug.vue │ │ │ │ │ ├── PipelineErrors.vue │ │ │ │ │ └── PipelineWrapper.vue │ │ │ │ └── settings/ │ │ │ │ ├── Actions.vue │ │ │ │ ├── Badge.vue │ │ │ │ ├── Crons.vue │ │ │ │ ├── Extensions.vue │ │ │ │ ├── General.vue │ │ │ │ ├── Registries.vue │ │ │ │ ├── RepoSettings.vue │ │ │ │ └── Secrets.vue │ │ │ └── user/ │ │ │ ├── UserAgents.vue │ │ │ ├── UserCLIAndAPI.vue │ │ │ ├── UserGeneral.vue │ │ │ ├── UserRegistries.vue │ │ │ ├── UserSecrets.vue │ │ │ └── UserWrapper.vue │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── web.go │ └── web_external.go └── woodpecker-go/ ├── LICENSE ├── README.md └── woodpecker/ ├── agent.go ├── agent_test.go ├── client.go ├── client_test.go ├── const.go ├── global_registry.go ├── global_secret.go ├── httputil/ │ ├── useragent.go │ └── useragent_test.go ├── interface.go ├── list_options.go ├── list_options_test.go ├── mocks/ │ └── mock_Client.go ├── org.go ├── pipeline.go ├── queue.go ├── queue_test.go ├── repo.go ├── repo_test.go ├── types.go ├── user.go └── user_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cspell.json ================================================ { "version": "0.2", "language": "en", "dictionaries": [ // language "en_us", // code "go", "node" ], "words": [ "abool", "addgroup", "adduser", "agentscan", "anbraten", "antfu", "apimachinery", "appleboy", "aquasec", "Archlinux", "autoincr", "automerge", "autoscaler", "backporting", "backports", "binutils", "bitbucketdatacenter", "Bluesky", "Boguslawski", "bradrydzewski", "buildkit", "BUILDPLATFORM", "buildx", "caddyfile", "ccmenu", "CERTDIR", "certmagic", "charmbracelet", "checkmake", "cicd", "ciphertext", "Cloudron", "Codeberg", "compatiblelicenses", "corepack", "cpuset", "creativecommons", "Curr", "datacenter", "DATASOURCE", "Debugf", "dejavusans", "Demilestoned", "desaturate", "devx", "dind", "Dockle", "doublestar", "emojify", "envsubst", "errgroup", "estree", "evenodd", "excalidraw", "favicons", "Fediverse", "Feishu", "Fogas", "forbidigo", "Forgejo", "fsnotify", "Geeklab", "Georgiana", "gitea", "gitmodules", "GOARCH", "GOBIN", "gocritic", "GODEBUG", "godoc", "Gogs", "golangci", "gomod", "gonic", "GOPATH", "Gource", "handlebargh", "HEALTHCHECK", "healthz", "Hetzner", "HETZNERCLOUD", "homelab", "hostmatcher", "HTMLURL", "HTTPFS", "httpsign", "HTTPURL", "httputil", "ianvs", "iconify", "inetutils", "Infima", "Infof", "Informatyka", "intlify", "Ionescu", "Jetpack", "Kaniko", "Keyfunc", "kyvg", "lafriks", "LASTEXITCODE", "Laszlo", "laszlocph", "letsencrypt", "loadbalancer", "logfile", "loglevel", "LONGBLOB", "LONGTEXT", "lonix1", "mapstructure", "markdownlint", "mdbook", "memswap", "Metas", "mhmxs", "Milestoned", "moby", "Msgf", "mstruebing", "multiarch", "multierr", "narqo", "netdns", "Netrc", "Nextcloud", "nfpm", "nixos", "nixpkgs", "nocolor", "nolint", "nologin", "norunningpipelines", "nosniff", "ntfy", "octocat", "openapi", "opensource", "opentype", "Pacman", "picus", "Pinia", "pkce", "pnpx", "Polyform", "posix", "ppid", "Println", "prismjs", "promauto", "promhttp", "proto", "protobuf", "protoc", "PROTOC", "protoimpl", "protoreflect", "pullrequest", "pullrequests", "pwsh", "Redirections", "Refspec", "regcred", "repology", "reslimit", "Reviewdog", "Rieter", "riscv", "rundll32", "Rydzewski", "seccomp", "secprofile", "selfhosted", "sess", "sfnt", "shellescape", "shopt", "sigstore", "Sonatype", "SSHURL", "sslmode", "stepbuilder", "stretchr", "structs", "sublicensable", "swaggo", "syscalls", "TARGETARCH", "TARGETOS", "techknowlogick", "termenv", "testdata", "threadcreate", "tink", "tinycolor", "tmole", "tmpfs", "tmpl", "tolerations", "Traefik", "tseslint", "ttlcache", "TUNEIT", "Tunnelmole", "typecheck", "Typeflag", "unplugin", "unsanitize", "Upsert", "urfave", "usecase", "useragent", "varchar", "varz", "vcsurl", "Vieter", "virtualisation", "visualisation", "vite", "vueuse", "waivable", "Warnf", "webhookd", "Weblate", "windi", "windicss", "woodpeckerci", "WORKDIR", "Wrapf", "x-enum-varnames", "xlink", "xlog", "xorm", "xormigrate", "xoxys", "xyaml", "yamls", "Yuno", "zerolog", "zerologger" ], "ignorePaths": [ ".cspell.json", "e2e/**", ".git/**/*", ".gitignore", ".golangci.yaml", ".vscode/extensions.json", "*_test.go", "*.excalidraw", "*.svg", "**/*.pb.go", "**/fixtures/**", "**/testdata/**", "CHANGELOG.md", "docs/versioned_docs/", "flake.nix", "go.mod", "Makefile", "package.json", "server/store/datastore/migration/**/*", "web/components.d.ts", "web/src/assets/locales/**/*", // generated "**/mocks/**", "**/node_modules/**/*", "cmd/server/openapi/docs.go", "flake.lock", "go.sum", "pnpm-lock.yaml", "renovate.json", // TODO: remove the following "docs/**/*.js", "docs/**/*.ts" ], // Exclude imports, because they are also strings. "ignoreRegExpList": [ // ignore mulltiline imports "import\\s*\\((.|[\r\n])*?\\)", // ignore single line imports "import\\s*.*\".*?\"", // ignore go generate directive "//\\s*go:generate.*", // ignore nolint directive "//\\s*nolint:.*", // ignore docker image names "\\s*docker\\.io/.*", // ignore inline svg in css "\\s*url\\(\"data:image/svg\\+xml.*" ], "enableFiletypes": ["dockercompose"] } ================================================ FILE: .ecrc ================================================ { "Exclude": [ ".git", "go.mod", "go.sum", "vendor", "fixtures", "LICENSE", "node_modules", "server/store/datastore/migration/test-files/sqlite.db", "server/store/datastore/migration/test-files/postgres.sql", "server/store/datastore/feed.go", "cmd/server/openapi/docs.go", "_test.go", "Makefile" ] } ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 tab_width = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.go] indent_style = tab [*.md] trim_trailing_whitespace = false indent_size = 1 [Makefile] indent_style = tab ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ # Credits to: https://github.com/vitejs/vite/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml name: "\U0001F41E Bug report" description: Report an issue with Woodpecker labels: ['bug'] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: dropdown id: component attributes: label: Component description: Which component of Woodpecker is affected by the issue? multiple: true options: - server - agent - cli - web-ui - other validations: required: true - type: textarea id: bug-description attributes: label: Describe the bug description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. Thanks! placeholder: Bug description validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to reproduce description: Steps to reproduce the behavior. placeholder: | 1. Install Woodpecker Server with the following configuration: ... 2. Install Woodpecker Agent with the configuration below: ... 3. Besides, set some settings in the forge: ... 4. Run them all by the commands: ... 5. Go to ..., click here and there, see next error: ... 6. Also, check the logs and find this: ... validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior description: A clear and concise description of what you expected to happen. placeholder: | When I click here and there, there should not be an error, but a successful operation. There should not be the errors in the logs, but the messages, that indicate a process: ... validations: required: false - type: textarea id: system-info attributes: label: System Info description: Output of `https:///version` render: shell placeholder: Version info, docker-compose config, Kubernetes manifests validations: required: true - type: textarea id: additional-context attributes: label: Additional context description: | Logs? Screenshots? Anything that will give us more context about the issue you are encountering! Sometimes a picture is worth a thousand words, but please try not to insert an image of logs / text and copy paste the text instead. Tip: You can attach images by clicking this area to highlight it and then dragging files in. validations: required: false - type: checkboxes id: checkboxes attributes: label: Validations description: Before submitting the issue, please make sure you do the following options: - label: Read the [docs](https://woodpecker-ci.org/docs/intro). required: true - label: Check that there isn't [already an issue](https://github.com/woodpecker-ci/woodpecker/issues) that reports the same bug to avoid creating a duplicate. required: true - label: Checked that the bug isn't fixed in the `next` version already [https://woodpecker-ci.org/versions] required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Start a discussion about: Our preferred starting point if you have any questions, suggestions or feature proposals. url: https://github.com/woodpecker-ci/woodpecker/discussions/new/choose - name: Frequently Asked Questions url: https://woodpecker-ci.org/faq about: Check the FAQs for common questions. - name: Support url: https://github.com/woodpecker-ci/.github/blob/main/SUPPORT.md about: Information about how you can get support. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ # Credits to: https://github.com/vitejs/vite/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml name: "\U0001F680 New feature proposal" description: Propose a new feature to be added to Woodpecker labels: ['feature'] body: - type: markdown attributes: value: | Thanks for your interest in the project and taking the time to fill out this feature report! - type: textarea id: feature-description attributes: label: Clear and concise description of the problem description: 'As a user of Woodpecker I want [goal / wish] so that [benefit]. If you intend to submit a PR for this issue, tell us in the description.' validations: required: true - type: textarea id: suggested-solution attributes: label: Suggested solution description: 'In web-ui / config we could provide following functionality...' validations: required: true - type: textarea id: alternative attributes: label: Alternative description: Clear and concise description of any alternative solutions or features you've considered. - type: textarea id: additional-context attributes: label: Additional context description: Any other context or screenshots about the feature request here. - type: checkboxes id: checkboxes attributes: label: Validations description: Before submitting the issue, please make sure you do the following options: - label: Checked that the feature isn't part of the `next` version already [https://woodpecker-ci.org/versions] required: true - label: Read the [docs](https://woodpecker-ci.org/docs/intro). required: true - label: Check that there isn't already an [issue](https://github.com/woodpecker-ci/woodpecker/issues) that request the same feature to avoid creating a duplicate. required: true ================================================ FILE: .github/pull_request_template.md ================================================ ================================================ FILE: .github/release_template.md ================================================ ### Prerequisites - [ ] MAJOR: Check `docs/src/pages/migrations.md` - [ ] Check whether it contains all the necessary migration steps and recommended actions for users and administrators - [ ] Check whether the steps refer to the associated pull requests or issues - [ ] Ensure that the steps are clear and describe the actions required for the migration - Good: "Rename your `branch` configuration option to `when.branch` (PR#123)" - Bad: "Remove the `branch` configuration option in favor of `when.branch`" - If possible, provide background information so users can understand the change - [ ] MAJOR: Create a blog entry in `docs/blog/` that highlights the most important changes and includes a link to the release notes. - [ ] Prepare docs PR for new version and delete old versions (keep only the last three minor versions for the current major version) - [ ] Run `make generate` locally to update the automatically generated CLI documentation - [ ] Copy `docs/docs` to `docs/versioned_docs/version-` and delete old versions - [ ] Create `docs/versioned_sidebars/version--sidebars.json` and delete old ones - [ ] Add new version to `docs/versions.json` and delete old versions - [ ] Add new version to the version list in `docs/src/pages/versions.md` - [ ] Announce the release in the maintainer chat and ask for pending blockers ### Release - [ ] Test the latest container images to make sure they work as expected - [ ] Update `https://ci.woodpecker.org` to the latest version of `next` and verify that it works as expected - [ ] Merge documentation PR (shortly before release) - [ ] Merge the release PR to start the release pipeline ### Post-release - [ ] Release the Helm Chart. If renovate has not created the upgrade PR already, manually trigger it from the Dependency Dashboard. - [ ] Announce release in relevant chats and on social media platforms - [ ] Mastodon (check if already posted from the release pipeline) - [ ] Bluesky (check if already posted from the release pipeline) - [ ] Matrix ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>woodpecker-ci/renovate-config"], "automergeType": "pr", "customManagers": [ { "customType": "regex", "managerFilePatterns": ["/^shared/constant/constant.go$/"], "matchStrings": [ "//\\s*renovate:\\s*datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\s+DefaultClonePlugin = \"docker.io/woodpeckerci/plugin-git:(?.*)\"" ], "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}" } ], "packageRules": [ { "matchCurrentVersion": "<1.0.0", "matchPackageNames": ["github.com/distribution/reference"], "matchUpdateTypes": ["major", "minor"], "dependencyDashboardApproval": true }, { "matchPackageNames": ["github.com/charmbracelet/huh/spinner"], "enabled": false }, { "matchManagers": ["docker-compose"], "matchFileNames": ["docker-compose.gitpod.yaml"], "addLabels": ["devx"] }, { "groupName": "golang-lang", "matchUpdateTypes": ["minor", "patch"], "matchPackageNames": ["/^golang$/", "/xgo/"] }, { "groupName": "golang-packages", "matchManagers": ["gomod"], "matchUpdateTypes": ["minor", "patch"] }, { "matchManagers": ["npm"], "matchFileNames": ["web/package.json"], "addLabels": ["ui"] }, { "matchManagers": ["npm"], "matchFileNames": ["docs/**/package.json"], "addLabels": ["documentation"] }, { "groupName": "web npm deps non-major", "matchManagers": ["npm"], "matchUpdateTypes": ["minor", "patch"], "matchFileNames": ["web/package.json"] }, { "groupName": "docs npm deps non-major", "matchManagers": ["npm"], "matchUpdateTypes": ["minor", "patch"], "matchFileNames": ["docs/**/package.json"] }, { "description": "Extract version from xgo container tags", "matchDatasources": ["docker"], "versioning": "regex:^go-(?\\d+)\\.(?\\d+)\\.x$", "matchPackageNames": ["/techknowlogick/xgo/"] } ] } ================================================ FILE: .gitignore ================================================ ### IDEs ### .idea/ .vscode/* !.vscode/settings.json !.vscode/launch.json !.vscode/extensions.json ### GO ### # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib vendor/ __debug_bin* # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out ### Frontend ### web/dist/** !web/dist/.gitkeep web/node_modules/ web/*.log web/.env .pnpm-store ### Docker ### docker-compose.yml ### Other ## # runetime or build relicts *.sqlite *.out /.env /.direnv /.envrc extras/ /build/ /dist/ /data/ datastore/migration/testfiles/ docs/venv # helm charts .cr-index/ .cr-release-packages/ ### Generated by CI ### docs/docs/40-cli.md docs/openapi.json # Removed once v3.0.x is minimum version to be touched docs/swagger.json ================================================ FILE: .gitpod.yml ================================================ tasks: - name: Server env: WOODPECKER_OPEN: true WOODPECKER_ADMIN: woodpecker WOODPECKER_EXPERT_WEBHOOK_HOST: http://host.docker.internal:8000 WOODPECKER_AGENT_SECRET: '1234' WOODPECKER_GITEA: true WOODPECKER_DEV_WWW_PROXY: http://localhost:8010 WOODPECKER_BACKEND_DOCKER_NETWORK: ci_default init: | # renovate: datasource=golang-version depName=golang GO_VERSION=1.26.3 rm -rf ~/go curl -fsSL https://dl.google.com/go/go$GO_VERSION.linux-amd64.tar.gz | tar xzs -C ~/ go mod tidy mkdir -p web/dist touch web/dist/index.html make build-server command: | grep "WOODPECKER_GITEA_URL=" .env \ && sed "s,^WOODPECKER_GITEA_URL=.*,WOODPECKER_GITEA_URL=$(gp url 3000)," .env \ || echo WOODPECKER_GITEA_URL=$(gp url 3000) >> .env grep "WOODPECKER_HOST=" .env \ && sed "s,^WOODPECKER_HOST=.*,WOODPECKER_HOST=$(gp url 8000)," .env \ || echo WOODPECKER_HOST=$(gp url 8000) >> .env gp sync-await gitea gp sync-done woodpecker-server go run go.woodpecker-ci.org/woodpecker/v3/cmd/server - name: Agent env: WOODPECKER_SERVER: localhost:9000 WOODPECKER_AGENT_SECRET: '1234' WOODPECKER_MAX_WORKFLOWS: 1 WOODPECKER_HEALTHCHECK: false command: | gp sync-await woodpecker-server go run go.woodpecker-ci.org/woodpecker/v3/cmd/agent - name: Gitea command: | export DOCKER_COMPOSE_CMD="docker-compose -f docker-compose.gitpod.yaml -p woodpecker" export GITEA_CLI_CMD="$DOCKER_COMPOSE_CMD exec -u git gitea gitea" $DOCKER_COMPOSE_CMD up -d until curl --output /dev/null --silent --head --fail http://localhost:3000; do printf '.'; sleep 1; done $GITEA_CLI_CMD admin user create --username woodpecker --password password --email woodpecker@localhost --admin export GITEA_TOKEN=$($GITEA_CLI_CMD admin user generate-access-token -u woodpecker --scopes write:repository,write:user --raw | tail -n 1 | awk 'NF{ print $NF }') GITEA_OAUTH_APP=$(curl -X 'POST' 'http://localhost:3000/api/v1/user/applications/oauth2' \ -H 'accept: application/json' -H 'Content-Type: application/json' -H "Authorization: token ${GITEA_TOKEN}" \ -d "{ \"name\": \"Woodpecker CI\", \"confidential_client\": true, \"redirect_uris\": [ \"https://8000-${GITPOD_WORKSPACE_ID}.${GITPOD_WORKSPACE_CLUSTER_HOST}/authorize\" ] }") touch .env grep "WOODPECKER_GITEA_CLIENT=" .env \ && sed "s,^WOODPECKER_GITEA_CLIENT=.*,WOODPECKER_GITEA_CLIENT=$(echo $GITEA_OAUTH_APP | jq -r .client_id)," .env \ || echo WOODPECKER_GITEA_CLIENT=$(echo $GITEA_OAUTH_APP | jq -r .client_id) >> .env grep "WOODPECKER_GITEA_SECRET=" .env \ && sed "s,^WOODPECKER_GITEA_SECRET=.*,WOODPECKER_GITEA_SECRET=$(echo $GITEA_OAUTH_APP | jq -r .client_secret)," .env \ || echo WOODPECKER_GITEA_SECRET=$(echo $GITEA_OAUTH_APP | jq -r .client_secret) >> .env curl -X 'POST' \ 'http://localhost:3000/api/v1/user/repos' \ -H 'accept: application/json' \ -H 'Content-Type: application/json' \ -H "Authorization: token ${GITEA_TOKEN}" \ -d '{ "auto_init": false, "name": "woodpecker-test", "private": true, "template": false, "trust_model": "default" }' cd contrib/woodpecker-test-repo git init git checkout -b main git remote add origin http://woodpecker:${GITEA_TOKEN}@localhost:3000/woodpecker/woodpecker-test.git git add . git commit -m "Initial commit" git push -u origin main cd ../.. gp sync-done gitea $DOCKER_COMPOSE_CMD logs -f - name: App before: | cd web/ init: | pnpm install command: | pnpm start - name: Docs before: | cd docs/ init: | pnpm install pnpm build:woodpecker-plugins command: | pnpm start --port 4000 ports: - port: 3000 name: Gitea onOpen: ignore visibility: public # TODO: https://github.com/woodpecker-ci/woodpecker/issues/856 - port: 8000 name: Woodpecker onOpen: notify visibility: public # TODO: https://github.com/woodpecker-ci/woodpecker/issues/856 - port: 9000 name: Woodpecker GRPC onOpen: ignore - port: 8010 description: Do not use! Access woodpecker on port 8000 onOpen: ignore - port: 4000 name: Docs onOpen: notify vscode: extensions: # cSpell:disable - 'golang.go' - 'EditorConfig.EditorConfig' - 'dbaeumer.vscode-eslint' - 'esbenp.prettier-vscode' - 'bradlc.vscode-tailwindcss' - 'Vue.volar' - 'redhat.vscode-yaml' - 'davidanson.vscode-markdownlint' - 'streetsidesoftware.code-spell-checker' - 'stivo.tailwind-fold' # cSpell:enable ================================================ FILE: .golangci.yaml ================================================ version: '2' run: timeout: 15m build-tags: - test linters: default: none enable: - asciicheck - bidichk - bodyclose - contextcheck - depguard - dogsled - durationcheck - errcheck - errchkjson - errorlint - forbidigo - forcetypeassert - gochecknoinits - gocritic - godot - goheader - gomoddirectives - gomodguard_v2 - goprintffuncname - govet - importas - ineffassign - makezero - misspell - mnd - nolintlint - revive - rowserrcheck - sqlclosecheck - staticcheck - unconvert - unparam - unused - usetesting - wastedassign - whitespace - zerologlint settings: depguard: rules: agent: list-mode: lax files: - '**/agent/*.go' - '**/agent/**/*.go' - '**/cmd/agent/*.go' - '**/cmd/agent/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/server - pkg: go.woodpecker-ci.org/woodpecker/v3/server - pkg: go.woodpecker-ci.org/woodpecker/v3/web - pkg: go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker cli: list-mode: lax files: - '**/cli/*.go' - '**/cli/**/*.go' - '**/cmd/cli/*.go' - '**/cmd/cli/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/server - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc - pkg: go.woodpecker-ci.org/woodpecker/v3/server - pkg: go.woodpecker-ci.org/woodpecker/v3/web pipeline: list-mode: lax files: - '!**/cli/pipeline/*.go' - '!**/cli/pipeline/**/*.go' - '!**/server/pipeline/*.go' - '!**/server/pipeline/**/*.go' - '**/pipeline/*.go' - '**/pipeline/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc - pkg: go.woodpecker-ci.org/woodpecker/v3/server - pkg: go.woodpecker-ci.org/woodpecker/v3/web server: list-mode: lax files: - '**/cmd/server/*.go' - '**/cmd/server/**/*.go' - '**/server/*.go' - '**/server/**/*.go' - '**/web/*.go' - '**/web/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker rpc: list-mode: lax files: - '!**/agent/rpc/*.go' - '!**/agent/rpc/**/*.go' - '!**/server/rpc/*.go' - '!**/server/rpc/**/*.go' - '**/rpc/*.go' - '**/rpc/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd - pkg: go.woodpecker-ci.org/woodpecker/v3/server - pkg: go.woodpecker-ci.org/woodpecker/v3/web shared: list-mode: lax files: - '!**/pipeline/shared/*.go' - '!**/pipeline/shared/**/*.go' - '**/shared/*.go' - '**/shared/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc - pkg: go.woodpecker-ci.org/woodpecker/v3/server - pkg: go.woodpecker-ci.org/woodpecker/v3/web woodpecker-go: list-mode: lax files: - '**/woodpecker-go/woodpecker/*.go' - '**/woodpecker-go/woodpecker/**/*.go' deny: - pkg: go.woodpecker-ci.org/woodpecker/v3/agent - pkg: go.woodpecker-ci.org/woodpecker/v3/cli - pkg: go.woodpecker-ci.org/woodpecker/v3/cmd - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline - pkg: go.woodpecker-ci.org/woodpecker/v3/rpc - pkg: go.woodpecker-ci.org/woodpecker/v3/server - pkg: go.woodpecker-ci.org/woodpecker/v3/shared - pkg: go.woodpecker-ci.org/woodpecker/v3/web errorlint: errorf-multi: true forbidigo: forbid: - pattern: context\.WithCancel$ - pattern: ^print.*$ - pattern: panic - pattern: ^log.Fatal().*$ godot: scope: toplevel exclude: - '^\s*cSpell:' - '^\s*TODO:' capital: true period: true importas: no-extra-aliases: true alias: # stdlib - pkg: log alias: std_log # grpc / protobuf - pkg: google.golang.org/grpc/metadata alias: grpc_metadata - pkg: google.golang.org/grpc/credentials alias: grpc_credentials - pkg: google.golang.org/protobuf/proto alias: grpc_proto # woodpecker internal - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types alias: backend_types - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/errors alias: pipeline_errors - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime alias: pipeline_runtime - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/utils alias: pipeline_utils - pkg: go.woodpecker-ci.org/woodpecker/v3/server/store/types alias: store_types - pkg: go.woodpecker-ci.org/woodpecker/v3/server/forge/types alias: forge_types - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/log alias: service_log - pkg: go.woodpecker-ci.org/woodpecker/v3/server/rpc alias: server_rpc - pkg: go.woodpecker-ci.org/woodpecker/v3/agent/rpc alias: agent_rpc - pkg: go.woodpecker-ci.org/woodpecker/v3/shared/utils alias: shared_utils - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata alias: pipeline_metadata - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base alias: yaml_base_types - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types alias: yaml_types - pkg: go.woodpecker-ci.org/woodpecker/v3/server/cron alias: cron_scheduler # mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks alias: secret_service_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks alias: registry_service_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/mocks alias: manager_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks alias: forge_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks alias: tracer_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks alias: queue_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/store/mocks alias: store_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks alias: config_service_mocks - pkg: go.woodpecker-ci.org/woodpecker/v3/server/services/log/mocks alias: log_mocks # kubernetes - pkg: k8s.io/api/core/v1 alias: kube_core_v1 - pkg: k8s.io/apimachinery/pkg/apis/meta/v1 alias: kube_meta_v1 - pkg: k8s.io/apimachinery/pkg/api/errors alias: kube_errors - pkg: k8s.io/client-go/tools/clientcmd alias: kube_client_cmd # docker - pkg: github.com/docker/cli/cli/config/types alias: docker_config_types # misc third-party - pkg: github.com/swaggo/files alias: swaggo_files - pkg: github.com/swaggo/gin-swagger alias: swaggo_gin_swagger - pkg: xorm.io/xorm/log alias: xlog - pkg: github.com/tink-crypto/tink-go/v2/insecurecleartextkeyset alias: insecure_clear_text_keyset - pkg: github.com/migueleliasweb/go-github-mock/src/mock alias: github_mock - pkg: gitlab.com/gitlab-org/api/client-go/v2 alias: gitlab misspell: locale: US mnd: ignored-numbers: - '0o600' - '0o660' - '0o644' - '0o755' - '0o700' ignored-functions: - make - time.* - strings.Split - callerName - random.GetRandomBytes revive: rules: - name: var-naming arguments: - [] - [] - - skipPackageNameChecks: true exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - mnd path: fixtures|cmd/agent/flags.go|cmd/server/flags.go|pipeline/backend/kubernetes/flags.go|_test.go paths: - third_party$ - builtin$ - examples$ formatters: enable: - gci - gofmt - gofumpt settings: gci: sections: - standard - default - prefix(go.woodpecker-ci.org/woodpecker) custom-order: true gofmt: simplify: true rewrite-rules: - pattern: interface{} replacement: any gofumpt: extra-rules: true exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .hadolint.yaml ================================================ ignored: - DL3018 # pin versions in Dockerfile ================================================ FILE: .lycheeignore ================================================ https://stackoverflow.com/* ================================================ FILE: .markdownlint.yaml ================================================ # markdownlint YAML configuration # https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml # Default state for all rules default: true # Path to configuration file to extend extends: null # MD003/heading-style/header-style - Heading style MD003: # Heading style style: 'atx' # MD004/ul-style - Unordered list style MD004: style: 'dash' # MD007/ul-indent - Unordered list indentation MD007: # Spaces for indent indent: 2 # Whether to indent the first level of the list start_indented: false # MD009/no-trailing-spaces - Trailing spaces MD009: # Spaces for line break br_spaces: 2 # Allow spaces for empty lines in list items list_item_empty_lines: false # Include unnecessary breaks strict: false # MD010/no-hard-tabs - Hard tabs MD010: # Include code blocks code_blocks: true # MD012/no-multiple-blanks - Multiple consecutive blank lines MD012: # Consecutive blank lines maximum: 1 # MD013/line-length - Line length MD013: # Number of characters line_length: 500 # Number of characters for headings heading_line_length: 100 # Number of characters for code blocks code_block_line_length: 80 # Include code blocks code_blocks: false # Include tables tables: false # Include headings headings: true # Strict length checking strict: false # Stern length checking stern: false # MD022/blanks-around-headings/blanks-around-headers - Headings should be surrounded by blank lines MD022: # Blank lines above heading lines_above: 1 # Blank lines below heading lines_below: 1 # MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content MD024: # Only check sibling headings siblings_only: true # MD025/single-title/single-h1 - Multiple top-level headings in the same document MD025: # Heading level level: 1 # RegExp for matching title in front matter front_matter_title: "^\\s*title\\s*[:=]" # MD026/no-trailing-punctuation - Trailing punctuation in heading MD026: # Punctuation characters punctuation: '.,;:!。,;:!' # MD029/ol-prefix - Ordered list item prefix MD029: # List style style: 'one_or_ordered' # MD030/list-marker-space - Spaces after list markers MD030: # Spaces for single-line unordered list items ul_single: 1 # Spaces for single-line ordered list items ol_single: 1 # Spaces for multi-line unordered list items ul_multi: 1 # Spaces for multi-line ordered list items ol_multi: 1 # MD033/no-inline-html - Inline HTML MD033: # Allowed elements allowed_elements: [details, summary, img, a, br, p] # MD035/hr-style - Horizontal rule style MD035: # Horizontal rule style style: '---' # MD036/no-emphasis-as-heading/no-emphasis-as-header - Emphasis used instead of a heading MD036: # Punctuation characters punctuation: '.,;:!?。,;:!?' # MD041/first-line-heading/first-line-h1 - First line in a file should be a top-level heading MD041: # Heading level level: 1 # RegExp for matching title in front matter front_matter_title: "^\\s*title\\s*[:=]" # MD044/proper-names - Proper names should have the correct capitalization MD044: # List of proper names # names: # Include code blocks code_blocks: false # MD046/code-block-style - Code block style MD046: # Block style style: 'fenced' # MD048/code-fence-style - Code fence style MD048: # Code fence style style: 'backtick' MD059: false ================================================ FILE: .mockery.yaml ================================================ --- all: true dir: '{{.InterfaceDir}}/mocks' filename: mock_{{.InterfaceName}}.go pkgname: mocks recursive: true packages: go.woodpecker-ci.org/woodpecker/v3/rpc: config: recursive: false go.woodpecker-ci.org/woodpecker/v3/server/forge: go.woodpecker-ci.org/woodpecker/v3/server/queue: go.woodpecker-ci.org/woodpecker/v3/server/services: config: exclude-subpkg-regex: - types go.woodpecker-ci.org/woodpecker/v3/server/services/config: go.woodpecker-ci.org/woodpecker/v3/server/services/environment: go.woodpecker-ci.org/woodpecker/v3/server/services/registry: go.woodpecker-ci.org/woodpecker/v3/server/services/secret: go.woodpecker-ci.org/woodpecker/v3/server/store: go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker: go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing: go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types: ================================================ FILE: .pre-commit-config.yaml ================================================ # cSpell:ignore checkmake hadolint autofix autoupdate repos: - repo: meta hooks: - id: check-hooks-apply - id: check-useless-excludes - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: end-of-file-fixer exclude: '\.sql$' - id: trailing-whitespace exclude: ^docs/versioned_docs/.+/40-cli.md$ - repo: https://github.com/golangci/golangci-lint rev: v2.12.2 hooks: - id: golangci-lint - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.48.0 hooks: - id: markdownlint exclude: '^(docs/versioned_docs/.*|CHANGELOG.md)$' language_version: 24.14.0 - repo: https://github.com/mrtazz/checkmake rev: v0.3.2 hooks: - id: checkmake exclude: '^docker/Dockerfile.make$' # actually a Dockerfile and not a makefile - repo: https://github.com/hadolint/hadolint rev: v2.14.0 hooks: - id: hadolint - repo: https://github.com/rbubley/mirrors-prettier rev: v3.8.3 hooks: - id: prettier - repo: https://github.com/adrienverge/yamllint.git rev: v1.38.0 hooks: - id: yamllint args: [--strict, -c=.yamllint.yaml] - repo: local hooks: - id: yaml-file-extension name: Check if YAML files has *.yaml extension. entry: YAML filenames must have .yaml extension. language: fail files: .yml$ exclude: '^(.gitpod.yml|.github/ISSUE_TEMPLATE/config.yml)$' ci: autofix_commit_msg: | [pre-commit.ci] auto fixes from pre-commit.com hooks [CI SKIP] for more information, see https://pre-commit.ci autofix_prs: true autoupdate_branch: '' autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' autoupdate_schedule: quarterly # NB: hadolint not included in pre-commit.ci skip: [check-hooks-apply, check-useless-excludes, hadolint, prettier, golangci-lint] submodules: false ================================================ FILE: .prettierignore ================================================ build/ dist/ CHANGELOG.md # web/ and docs/ must be directly formatted from there # to prevent conflicts with different prettier version web/ docs/ ================================================ FILE: .prettierrc.json ================================================ { "semi": true, "trailingComma": "all", "singleQuote": true, "printWidth": 120, "tabWidth": 2, "endOfLine": "lf" } ================================================ FILE: .vscode/extensions.json ================================================ { // List of extensions which should be recommended for users of this workspace. "recommendations": [ "golang.go", "EditorConfig.EditorConfig", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss", "Vue.volar", "redhat.vscode-yaml", "davidanson.vscode-markdownlint", "stivo.tailwind-fold" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "compounds": [ { "name": "Woodpecker CI", "configurations": ["Woodpecker UI", "Woodpecker server", "Woodpecker agent"], "stopAll": true } ], "configurations": [ { "name": "Woodpecker server", "type": "go", "request": "launch", "mode": "debug", "program": "${workspaceFolder}/cmd/server/", "cwd": "${workspaceFolder}" }, { "name": "Woodpecker agent", "type": "go", "request": "launch", "mode": "debug", "program": "${workspaceFolder}/cmd/agent/", "cwd": "${workspaceFolder}" }, { "name": "Go: current file", "type": "go", "request": "launch", "mode": "debug", "console": "integratedTerminal", "envFile": "${workspaceFolder}/.env", "cwd": "${workspaceFolder}", "program": "${file}" }, { "name": "Woodpecker UI", "type": "node", "request": "launch", "runtimeExecutable": "pnpm", "runtimeArgs": ["start"], "cwd": "${workspaceFolder}/web", "resolveSourceMapLocations": ["${workspaceFolder}/web/**", "!**/node_modules/**"], "envFile": "${workspaceFolder}/.env", "skipFiles": ["/**"] } ] } ================================================ FILE: .vscode/settings.json ================================================ { "git.ignoreLimitWarning": true, "search.exclude": { "**/node_modules": true, "**/bower_components": true, "**/*.code-search": true, "vendor/": true }, "go.lintTool": "golangci-lint", "go.lintFlags": ["--fast"], "go.buildTags": "test", "eslint.workingDirectories": ["./web"], "prettier.ignorePath": "./web/.prettierignore", // Auto fix "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", "source.organizeImports": "never" } } ================================================ FILE: .woodpecker/binaries.yaml ================================================ when: - event: tag - event: pull_request branch: ${CI_REPO_DEFAULT_BRANCH} path: - Makefile - .woodpecker/binaries.yaml variables: - &golang_image 'docker.io/golang:1.26' - &node_image 'docker.io/node:24-alpine' - &xgo_image 'docker.io/techknowlogick/xgo:go-1.26.x' # cspell:words bindata netgo steps: build-web: image: *node_image directory: web/ commands: - corepack enable - pnpm install --frozen-lockfile - pnpm build vendor: image: *golang_image commands: - go mod vendor cross-compile-server: depends_on: - vendor - build-web image: *xgo_image pull: true commands: - apt update - apt install -y tree - make cross-compile-server environment: PLATFORMS: linux|arm64/v8;linux|amd64;linux|riscv64;windows|amd64 TAGS: bindata sqlite sqlite_unlock_notify netgo ARCHIVE_IT: '1' build-tarball: depends_on: - vendor - build-web image: *golang_image commands: - make build-tarball build-agent: depends_on: - vendor image: *golang_image commands: - apt update - apt install -y zip - make release-agent build-cli: depends_on: - vendor image: *golang_image commands: - apt update - apt install -y zip - make release-cli build-deb-rpm: depends_on: - cross-compile-server - build-agent - build-cli image: *golang_image commands: - make bundle checksums: depends_on: - cross-compile-server - build-agent - build-cli - build-deb-rpm - build-tarball image: *golang_image commands: - make release-checksums release-dryrun: depends_on: - checksums image: *golang_image commands: - ls -la dist/*.* - cat dist/checksums.txt release: depends_on: - checksums image: woodpeckerci/plugin-release:0.3.1 settings: api_key: from_secret: github_token files: - dist/*.tar.gz - dist/*.zip - dist/*.deb - dist/*.rpm - dist/checksums.txt title: ${CI_COMMIT_TAG##v} when: event: tag ================================================ FILE: .woodpecker/check-feature-docs.sh ================================================ #!/bin/sh DOCS_CHANGED=$(echo "$CI_PIPELINE_FILES" | jq -r '.[]' | grep -c '^docs/docs/' || true) if [ "$DOCS_CHANGED" -gt 0 ]; then echo "✅ OK: docs/docs/ has changes" exit 0 fi NON_CLI=$(echo "$CI_PIPELINE_FILES" | jq -r '.[]' | grep -v '^cli/' | grep -v '^cmd/cli/' | grep -v '^docs/' || true) if [ -z "$NON_CLI" ]; then echo "✅ OK: CLI-only feature, docs are auto-generated" exit 0 fi echo "🚨 ERROR: PR has 'feature' label but no changes in docs/docs/" echo "Please add documentation for the new feature." exit 1 ================================================ FILE: .woodpecker/docker.yaml ================================================ variables: - &golang_image 'docker.io/golang:1.26' - &node_image 'docker.io/node:24-alpine' - &xgo_image 'docker.io/techknowlogick/xgo:go-1.26.x' - &buildx_plugin 'docker.io/woodpeckerci/plugin-docker-buildx:6.1.0' - &platforms_release 'linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/386,linux/amd64,linux/ppc64le,linux/riscv64,linux/s390x,freebsd/arm64,freebsd/amd64,openbsd/arm64,openbsd/amd64' - &platforms_server 'linux/arm/v7,linux/arm64/v8,linux/amd64,linux/ppc64le,linux/riscv64' - &platforms_preview 'linux/amd64' - &platforms_alpine 'linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/amd64,linux/ppc64le' - &build_args 'CI_COMMIT_SHA=${CI_COMMIT_SHA},CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH},CI_COMMIT_TAG=${CI_COMMIT_TAG}' # cspell:words woodpeckerbot netgo # vars used on push / tag events only - publish_logins: &publish_logins # Default DockerHub login - registry: https://index.docker.io/v1/ username: woodpeckerbot password: from_secret: docker_password # Additional Quay.IO login - registry: https://quay.io username: 'woodpeckerci+wp_ci' password: from_secret: QUAY_IO_TOKEN - &publish_repos_server 'woodpeckerci/woodpecker-server,quay.io/woodpeckerci/woodpecker-server' - &publish_repos_agent 'woodpeckerci/woodpecker-agent,quay.io/woodpeckerci/woodpecker-agent' - &publish_repos_cli 'woodpeckerci/woodpecker-cli,quay.io/woodpeckerci/woodpecker-cli' - path: &when_path # web source code - 'web/**' # api source code - 'server/api/**' # go source code - '**/*.go' - 'go.*' # schema changes - 'pipeline/schema/**' # Dockerfile changes - 'docker/**' # pipeline config changes - '.woodpecker/docker.yaml' when: - event: [pull_request, tag] - event: push branch: ${CI_REPO_DEFAULT_BRANCH} path: *when_path - event: pull_request_metadata evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_images"' steps: vendor: image: *golang_image pull: true commands: - go mod vendor when: - event: [pull_request, pull_request_metadata] evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_images"' - event: pull_request path: *when_path - branch: - ${CI_REPO_DEFAULT_BRANCH} event: [push, tag] path: *when_path ############### # S e r v e r # ############### build-web: image: *node_image directory: web/ commands: - corepack enable - pnpm install --frozen-lockfile - pnpm build when: - event: [pull_request, pull_request_metadata] evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_images"' - event: pull_request path: *when_path - branch: - ${CI_REPO_DEFAULT_BRANCH} event: [push, tag] path: *when_path cross-compile-server-preview: depends_on: - vendor - build-web image: *xgo_image pull: true commands: - apt update - apt install -y tree - make cross-compile-server environment: PLATFORMS: linux|amd64 TAGS: sqlite sqlite_unlock_notify netgo when: - event: [pull_request, pull_request_metadata] evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_images"' - event: pull_request path: *when_path cross-compile-server: depends_on: - vendor - build-web image: *xgo_image pull: true commands: - apt update - apt install -y tree - make cross-compile-server environment: PLATFORMS: linux|arm/v7;linux|arm64/v8;linux|amd64;linux|ppc64le;linux|riscv64 TAGS: sqlite sqlite_unlock_notify netgo when: branch: - ${CI_REPO_DEFAULT_BRANCH} event: [push, tag] path: *when_path publish-server-alpine-preview: depends_on: - cross-compile-server-preview image: *buildx_plugin settings: repo: woodpeckerci/woodpecker-server dockerfile: docker/Dockerfile.server.alpine.multiarch.rootless platforms: *platforms_preview tag: pull_${CI_COMMIT_PULL_REQUEST}-alpine logins: *publish_logins when: &when-preview evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_images"' event: [pull_request, pull_request_metadata] build-server-dryrun: depends_on: - vendor - build-web - cross-compile-server-preview image: *buildx_plugin settings: dry_run: true repo: woodpeckerci/woodpecker-server dockerfile: docker/Dockerfile.server.multiarch.rootless platforms: *platforms_preview tag: pull_${CI_COMMIT_PULL_REQUEST} when: &when-dryrun - evaluate: 'not (CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_images")' event: pull_request path: *when_path publish-next-server: depends_on: - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_server dockerfile: docker/Dockerfile.server.multiarch.rootless platforms: *platforms_server tag: [next, 'next-${CI_COMMIT_SHA:0:10}'] logins: *publish_logins when: &when-publish-next branch: ${CI_REPO_DEFAULT_BRANCH} event: push path: *when_path publish-next-server-alpine: depends_on: - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_server dockerfile: docker/Dockerfile.server.alpine.multiarch.rootless platforms: *platforms_alpine tag: [next-alpine, 'next-${CI_COMMIT_SHA:0:10}-alpine'] logins: *publish_logins when: *when-publish-next release-server: depends_on: - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_server dockerfile: docker/Dockerfile.server.multiarch.rootless platforms: *platforms_server tag: ['${CI_COMMIT_TAG%%.*}', '${CI_COMMIT_TAG%.*}', '${CI_COMMIT_TAG}'] logins: *publish_logins when: &when-release event: tag release-server-alpine: depends_on: - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_server dockerfile: docker/Dockerfile.server.alpine.multiarch.rootless platforms: *platforms_alpine tag: ['${CI_COMMIT_TAG%%.*}-alpine', '${CI_COMMIT_TAG%.*}-alpine', '${CI_COMMIT_TAG}-alpine'] logins: *publish_logins when: *when-release ############# # A g e n t # ############# publish-agent-preview-alpine: depends_on: - vendor image: *buildx_plugin settings: repo: woodpeckerci/woodpecker-agent dockerfile: docker/Dockerfile.agent.alpine.multiarch platforms: *platforms_preview tag: pull_${CI_COMMIT_PULL_REQUEST}-alpine build_args: *build_args logins: *publish_logins when: *when-preview build-agent-dryrun: depends_on: - vendor image: *buildx_plugin settings: dry_run: true repo: woodpeckerci/woodpecker-agent dockerfile: docker/Dockerfile.agent.multiarch platforms: *platforms_preview tag: pull_${CI_COMMIT_PULL_REQUEST} build_args: *build_args when: *when-dryrun publish-next-agent: depends_on: - vendor # we also depend on cross-compile-server as we would have to hight # ram usage otherwise - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_agent dockerfile: docker/Dockerfile.agent.multiarch platforms: *platforms_release buildkit_oci_max_parallelism: 6 tag: [next, 'next-${CI_COMMIT_SHA:0:10}'] logins: *publish_logins build_args: *build_args when: branch: ${CI_REPO_DEFAULT_BRANCH} event: push path: *when_path publish-next-agent-alpine: depends_on: - vendor # we also depend on cross-compile-server as we would have to hight # ram usage otherwise - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_agent dockerfile: docker/Dockerfile.agent.alpine.multiarch platforms: *platforms_alpine tag: [next-alpine, 'next-${CI_COMMIT_SHA:0:10}-alpine'] logins: *publish_logins build_args: *build_args when: *when-publish-next release-agent: depends_on: - vendor # we also depend on cross-compile-server as we would have to hight # ram usage otherwise - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_agent dockerfile: docker/Dockerfile.agent.multiarch platforms: *platforms_release buildkit_oci_max_parallelism: 6 tag: ['${CI_COMMIT_TAG%%.*}', '${CI_COMMIT_TAG%.*}', '${CI_COMMIT_TAG}'] logins: *publish_logins build_args: *build_args when: *when-release release-agent-alpine: depends_on: - vendor # we also depend on cross-compile-server as we would have to hight # ram usage otherwise - cross-compile-server image: *buildx_plugin settings: repo: *publish_repos_agent dockerfile: docker/Dockerfile.agent.alpine.multiarch platforms: *platforms_alpine tag: ['${CI_COMMIT_TAG%%.*}-alpine', '${CI_COMMIT_TAG%.*}-alpine', '${CI_COMMIT_TAG}-alpine'] logins: *publish_logins build_args: *build_args when: *when-release ######### # C L I # ######### build-cli-alpine-preview: depends_on: - vendor image: *buildx_plugin settings: repo: woodpeckerci/woodpecker-cli dockerfile: docker/Dockerfile.cli.alpine.multiarch.rootless platforms: *platforms_preview tag: pull_${CI_COMMIT_PULL_REQUEST}-alpine build_args: *build_args logins: *publish_logins when: *when-preview build-cli-dryrun: depends_on: - vendor image: *buildx_plugin settings: dry_run: true repo: woodpeckerci/woodpecker-cli dockerfile: docker/Dockerfile.cli.multiarch.rootless platforms: *platforms_preview tag: pull_${CI_COMMIT_PULL_REQUEST} build_args: *build_args when: *when-dryrun publish-next-cli: depends_on: - vendor # we also depend on publish-next-agent as we would have to hight # ram usage otherwise - publish-next-agent image: *buildx_plugin settings: repo: *publish_repos_cli dockerfile: docker/Dockerfile.cli.multiarch.rootless platforms: *platforms_release buildkit_oci_max_parallelism: 6 tag: [next, 'next-${CI_COMMIT_SHA:0:10}'] logins: *publish_logins build_args: *build_args when: *when-publish-next publish-next-cli-alpine: depends_on: - vendor # we also depend on publish-next-agent as we would have to hight # ram usage otherwise - publish-next-agent image: *buildx_plugin settings: repo: *publish_repos_cli dockerfile: docker/Dockerfile.cli.alpine.multiarch.rootless platforms: *platforms_alpine tag: [next-alpine, 'next-${CI_COMMIT_SHA:0:10}-alpine'] logins: *publish_logins build_args: *build_args when: *when-publish-next release-cli: depends_on: - vendor # we also depend on release-agent as we would have to hight # ram usage otherwise - release-agent image: *buildx_plugin settings: repo: *publish_repos_cli dockerfile: docker/Dockerfile.cli.multiarch.rootless platforms: *platforms_release buildkit_oci_max_parallelism: 6 tag: ['${CI_COMMIT_TAG%%.*}', '${CI_COMMIT_TAG%.*}', '${CI_COMMIT_TAG}'] logins: *publish_logins build_args: *build_args when: *when-release release-cli-alpine: depends_on: - vendor # we also depend on release-agent as we would have to hight # ram usage otherwise - release-agent image: *buildx_plugin settings: repo: *publish_repos_cli dockerfile: docker/Dockerfile.cli.alpine.multiarch.rootless platforms: *platforms_alpine tag: ['${CI_COMMIT_TAG%%.*}-alpine', '${CI_COMMIT_TAG%.*}-alpine', '${CI_COMMIT_TAG}-alpine'] logins: *publish_logins build_args: *build_args when: *when-release ================================================ FILE: .woodpecker/docs.yaml ================================================ variables: - &golang_image 'docker.io/golang:1.26' - &node_image 'docker.io/node:24-alpine' - &alpine_image 'docker.io/alpine:3.23' - path: &when_path - 'docs/**' - '.woodpecker/docs.yaml' # since we generate docs for cli tool we have to watch this too - 'cli/**' - 'cmd/cli/**' # api docs - 'server/api/**' - path: &docker_path # web source code - 'web/**' # api source code - 'server/api/**' # go source code - '**/*.go' - 'go.*' # schema changes - 'pipeline/schema/**' # Dockerfile changes - 'docker/**' when: - event: tag - event: pull_request - event: push path: - <<: *when_path - <<: *docker_path branch: - ${CI_REPO_DEFAULT_BRANCH} - event: pull_request_closed path: *when_path - event: manual evaluate: 'TASK == "docs"' steps: - name: install-dependencies image: *node_image directory: docs/ commands: - corepack enable - pnpm install --frozen-lockfile when: - path: *when_path event: [tag, pull_request, push] - event: manual - name: format-check image: *node_image directory: docs/ commands: - corepack enable - pnpm format:check when: - path: *when_path event: pull_request - name: build-cli image: *golang_image commands: - make generate-docs when: - path: *when_path event: [tag, pull_request, push] - event: manual - name: build image: *node_image directory: docs/ commands: - corepack enable - pnpm build when: - path: *when_path event: [tag, pull_request, push] - event: manual - name: deploy-preview image: docker.io/woodpeckerci/plugin-surge-preview:1.4.2 settings: path: 'docs/build/' surge_token: from_secret: SURGE_TOKEN forge_repo_token: from_secret: GITHUB_TOKEN_SURGE failure: ignore when: - event: [pull_request, pull_request_closed] path: *when_path - name: deploy-prepare image: *alpine_image environment: BOT_PRIVATE_KEY: from_secret: BOT_PRIVATE_KEY commands: - apk add openssh-client git - mkdir -p $HOME/.ssh - ssh-keyscan -t rsa github.com >> $HOME/.ssh/known_hosts - echo "$BOT_PRIVATE_KEY" > $HOME/.ssh/id_rsa - chmod 0600 $HOME/.ssh/id_rsa - git clone --depth 1 --single-branch git@github.com:woodpecker-ci/woodpecker-ci.github.io.git ./docs_repo when: - event: push path: - <<: *when_path - <<: *docker_path branch: ${CI_REPO_DEFAULT_BRANCH} - event: [manual, tag] # update latest and next version - name: version-next image: *alpine_image commands: - apk add jq - jq '.next = "next-${CI_COMMIT_SHA:0:10}"' ./docs_repo/version.json > ./docs_repo/version.json.tmp - mv ./docs_repo/version.json.tmp ./docs_repo/version.json when: - event: push path: *docker_path branch: ${CI_REPO_DEFAULT_BRANCH} - name: version-release image: *alpine_image commands: - apk add jq - if [[ "${CI_COMMIT_TAG}" != *"rc"* ]] ; then jq '.latest = "${CI_COMMIT_TAG}"' ./docs_repo/version.json > ./docs_repo/version.json.tmp && mv ./docs_repo/version.json.tmp ./docs_repo/version.json ; fi - jq '.rc = "${CI_COMMIT_TAG}"' ./docs_repo/version.json > ./docs_repo/version.json.tmp - mv ./docs_repo/version.json.tmp ./docs_repo/version.json when: - event: tag - name: copy-files image: *alpine_image commands: - apk add rsync # copy all docs files and delete all old ones, but leave CNAME, index.yaml and version.json untouched - rsync -r --exclude .git --exclude CNAME --exclude index.yaml --exclude README.md --exclude version.json --delete docs/build/ ./docs_repo when: - event: push path: *when_path branch: ${CI_REPO_DEFAULT_BRANCH} - event: manual - name: deploy image: *alpine_image environment: BOT_PRIVATE_KEY: from_secret: BOT_PRIVATE_KEY commands: - apk add openssh-client rsync git - mkdir -p $HOME/.ssh - ssh-keyscan -t rsa github.com >> $HOME/.ssh/known_hosts - echo "$BOT_PRIVATE_KEY" > $HOME/.ssh/id_rsa - chmod 0600 $HOME/.ssh/id_rsa - git config --global user.email "woodpecker-bot@obermui.de" - git config --global user.name "woodpecker-bot" - cd ./docs_repo - git add . # exit successfully if nothing changed - test -n "$(git status --porcelain)" || exit 0 - git commit -m "Deploy website - based on ${CI_COMMIT_SHA}" - git push when: - event: push path: - <<: *when_path - <<: *docker_path branch: ${CI_REPO_DEFAULT_BRANCH} - event: [manual, tag] ================================================ FILE: .woodpecker/links.yaml ================================================ when: - event: cron cron: links steps: - name: links image: docker.io/lycheeverse/lychee:0.24.2 failure: ignore depends_on: [] commands: - lychee pipeline/frontend/yaml/linter/schema/schema.json > links.md - lychee --exclude localhost docs/docs/ >> links.md - lychee --exclude localhost docs/src/pages/ >> links.md - echo -e "\nLast checked:$(date)" >> links.md - name: Update issue image: docker.io/alpine:3.23 depends_on: links environment: GITHUB_TOKEN: from_secret: github_token commands: - apk add -q --no-cache jq curl - export ISSUE_NUMBER=5326 - export DESCRIPTION=$(cat links.md) - | curl -X PATCH \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Accept: application/vnd.github.v3+json" \ https://api.github.com/repos/${CI_REPO}/issues/$ISSUE_NUMBER \ -d "$(jq -n --arg body "$DESCRIPTION" '{body: $body}')" ================================================ FILE: .woodpecker/release-helper.yaml ================================================ when: - event: push branch: - ${CI_REPO_DEFAULT_BRANCH} - release/* steps: - name: release-helper image: docker.io/woodpeckerci/plugin-ready-release-go:4.1.1 settings: release_branch: ${CI_COMMIT_BRANCH} forge_type: github git_email: woodpecker-bot@obermui.de github_token: from_secret: GITHUB_TOKEN ================================================ FILE: .woodpecker/securityscan.yaml ================================================ when: - event: [pull_request] - event: push branch: - ${CI_REPO_DEFAULT_BRANCH} variables: - &trivy_plugin docker.io/woodpeckerci/plugin-trivy:1.4.5 steps: backend: depends_on: [] image: *trivy_plugin settings: server: server skip-dirs: web/,docs/ docs: depends_on: [] image: *trivy_plugin settings: server: server skip-dirs: node_modules/,plugins/woodpecker-plugins/node_modules/ dir: docs/ web: depends_on: [] image: *trivy_plugin settings: server: server skip-dirs: node_modules/ dir: web/ services: server: image: *trivy_plugin failure: ignore # as we don't care about the exit code settings: service: true db-repository: mirror.gcr.io/aquasec/trivy-db:2 ports: - 10000 ================================================ FILE: .woodpecker/social.yaml ================================================ depends_on: - docker - binaries when: - event: tag evaluate: 'CI_COMMIT_TAG matches "^v?[0-9]+\\\\.[0-9]+\\\\.[0-9]+$"' steps: - name: mastodon-toot image: docker.io/woodpeckerci/plugin-mastodon-post settings: server_url: https://floss.social access_token: from_secret: mastodon_token visibility: public ai_token: from_secret: openai_token ai_prompt: | We want to present the next version of our app on Mastodon. Therefore we want to post a catching text, so users will know why they should update to the newest version. Highlight the most special features. If there is no special feature included just summarize the changes in a few sentences. The whole text should not be longer than 240 characters. Avoid naming contributors from. Use #WoodpeckerCI, #release and additional fitting hashtags and emojis to make the post more appealing The changelog entry: {{ changelog }} - name: bluesky-post image: docker.io/woodpeckerci/plugin-bluesky-post settings: app_password: from_secret: bluesky_token identifier: woodpecker-ci.org ai_token: from_secret: openai_token ai_prompt: | We want to present the next version of our app on Mastodon. Therefore we want to post a catching text, so users will know why they should update to the newest version. Highlight the most special features. If there is no special feature included just summarize the changes in a few sentences. The whole text should not be longer than 240 characters. Avoid naming contributors from. Use #WoodpeckerCI, #release and additional fitting hashtags and emojis to make the post more appealing The changelog entry: {{ changelog }} ================================================ FILE: .woodpecker/static.yaml ================================================ when: - event: pull_request steps: - name: lint-editorconfig image: docker.io/woodpeckerci/plugin-editorconfig-checker:0.3.3 depends_on: [] when: - event: pull_request - name: spellcheck image: docker.io/node:24-alpine depends_on: [] commands: - corepack enable - pnpx cspell lint --no-progress --gitignore '{**,.*}/{*,.*}' - apk add --no-cache -U tree # busybox tree don't understand "-I" # cspell:disable-next-line - tree --gitignore -I 012_columns_rename_procs_to_steps.go -I versioned_docs -I '*opensource.svg'| pnpx cspell lint --no-progress stdin - name: prettier image: docker.io/woodpeckerci/plugin-prettier:next pull: true depends_on: [] settings: version: 3.6.2 - name: check-feature-docs image: docker.io/alpine:3.23 depends_on: [] commands: - apk add --no-cache -q jq && ./.woodpecker/check-feature-docs.sh when: - event: pull_request evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "feature"' - name: agentscan image: docker.io/woodpeckerci/plugin-agentscan:latest pull: true depends_on: [] settings: github_token: from_secret: GITHUB_TOKEN_SURGE allowlist: - woodpecker-bot - 'renovate[bot]' - 6543 - anbraten - lafriks - qwerty287 - xoxys when: - event: pull_request ================================================ FILE: .woodpecker/test.yaml ================================================ variables: - &golang_image 'docker.io/golang:1.26' - &when - path: &when_path # related config files - '.woodpecker/test.yaml' - '.golangci.yaml' # go source code - '**/*.go' - 'go.*' # schema changes - 'pipeline/schema/**' # tools updates - Makefile - 'codecov.yaml' event: pull_request when: - event: pull_request - event: push branch: ${CI_REPO_DEFAULT_BRANCH} path: *when_path steps: vendor: image: *golang_image commands: - go mod vendor when: path: - <<: *when_path - '.woodpecker/**' lint-pipeline: depends_on: - vendor image: *golang_image commands: - go run go.woodpecker-ci.org/woodpecker/v3/cmd/cli lint environment: WOODPECKER_DISABLE_UPDATE_CHECK: true WOODPECKER_LINT_STRICT: true WOODPECKER_PLUGINS_PRIVILEGED: 'docker.io/woodpeckerci/plugin-docker-buildx' when: - event: pull_request path: - '.woodpecker/**' dummy-web: image: *golang_image commands: - mkdir -p web/dist/ - echo "test" > web/dist/index.html when: - path: *when_path lint: depends_on: - vendor image: golangci/golangci-lint:v2.12.2 commands: - make lint when: *when check-openapi: depends_on: - vendor image: *golang_image commands: - 'make generate-openapi' - 'DIFF=$(git diff | head)' - '[ -n "$DIFF" ] && { echo "openapi not up to date, exec `make generate-openapi` and commit"; exit 1; } || true' when: *when lint-license-header: image: *golang_image commands: - make install-addlicense # cspell:words addlicense - bash -c 'shopt -s globstar; addlicense -check -ignore "vendor/**" -ignore cmd/server/openapi/docs.go **/*.go' when: *when test: depends_on: - vendor image: *golang_image commands: - make test-agent - make test-server - make test-cli - make test-lib when: - path: *when_path test-e2e: depends_on: - vendor image: *golang_image commands: - make test-e2e when: - path: *when_path sqlite: depends_on: - vendor image: *golang_image environment: WOODPECKER_DATABASE_DRIVER: sqlite3 commands: - make test-server-datastore-coverage when: - path: *when_path postgres: depends_on: - vendor image: *golang_image environment: WOODPECKER_DATABASE_DRIVER: postgres WOODPECKER_DATABASE_DATASOURCE: 'host=postgres user=postgres dbname=postgres sslmode=disable' # cspell:disable-line commands: - make test-server-datastore when: *when mysql: depends_on: - vendor image: *golang_image environment: WOODPECKER_DATABASE_DRIVER: mysql WOODPECKER_DATABASE_DATASOURCE: root@tcp(mysql:3306)/test?parseTime=true commands: - make test-server-datastore when: *when codecov: depends_on: - test - sqlite pull: true image: docker.io/woodpeckerci/plugin-codecov:2.3.1 settings: files: - agent-coverage.out - cli-coverage.out - coverage.out - server-coverage.out - datastore-coverage.out - e2e-coverage.out token: from_secret: codecov_token when: - path: *when_path failure: ignore services: postgres: image: docker.io/postgres:18 ports: ['5432'] environment: POSTGRES_USER: postgres POSTGRES_HOST_AUTH_METHOD: trust when: *when mysql: image: docker.io/mysql:9.7.0 ports: ['3306'] environment: MYSQL_DATABASE: test MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' when: *when ================================================ FILE: .woodpecker/web.yaml ================================================ when: - event: pull_request - event: push branch: - release/* variables: - &node_image 'docker.io/node:24-alpine' - &when path: # related config files - '.woodpecker/web.yaml' # web source code - 'web/**' # api source code - 'server/api/**' steps: install-dependencies: image: *node_image directory: web/ commands: - corepack enable - pnpm install --frozen-lockfile when: *when lint: depends_on: - install-dependencies image: *node_image directory: web/ commands: - corepack enable - pnpm lint when: *when format-check: depends_on: - install-dependencies image: *node_image directory: web/ commands: - corepack enable - pnpm format:check when: *when typecheck: depends_on: - install-dependencies image: *node_image directory: web/ commands: - corepack enable - pnpm typecheck when: *when test: depends_on: - install-dependencies - format-check # wait for it else test artifacts are falsely detected as wrong image: *node_image directory: web/ commands: - corepack enable - pnpm test when: *when ================================================ FILE: .yamllint.yaml ================================================ extends: default ignore-from-file: - docs/.gitignore - docs/plugins/woodpecker-plugins/.gitignore - .gitignore - server/store/datastore/migration/test-files/.gitignore - web/.gitignore - web/.yamlignore rules: line-length: disable document-start: disable comments: disable ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [3.14.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.14.1) - 2026-05-12 ### ❤️ Special thanks the security researchers and those who fixed them ❤️ - Thanks to **Shivam Kumar ([@shivamkumarcyber](https://github.com/shivamkumarcyber))** and **Ranganatha Rao Sridhar (Praetorian)** _independently finding and reporting the bug_ - And [@6543](https://github.com/6543) _fixing the bugs and orchestrating the communication_ ### 🔒 Security - Server: make sure agent_id can not be spoofed by agent [[#6567](https://github.com/woodpecker-ci/woodpecker/pull/6567)] ## [3.14.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.14.0) - 2026-05-01 ### ❤️ Thanks to all contributors! ❤️ @6543, @Aex12, @AhmadNajiKam, @CrimsonFez, @LUKIEYF, @LoricAndre, @M31ancholy, @MartinSchmidt, @Pnkcaht, @Sim-hu, @TumbleOwlee, @api2062, @bclermont, @brainbaking, @cliffmccarthy, @confusedsushi, @dccdis, @hhamalai, @hnb2, @lephuongbg, @mehrdadbn9, @mofr93, @myers, @myselfghost, @njaaazi, @packrat386, @paulovitorbal, @qwerty287, @rfinnie, @rhafer, @samoli, @savv, @stardothosting, @utafrali, @wucm667 ### 🔒 Security - docs: bump follow-redirects [[#6441](https://github.com/woodpecker-ci/woodpecker/pull/6441)] - chore(deps): update dependency axios to v1.15.0 [security] [[#6417](https://github.com/woodpecker-ci/woodpecker/pull/6417)] - fix(deps): update go.opentelemetry.io/otel to v1.43.0 [[#6416](https://github.com/woodpecker-ci/woodpecker/pull/6416)] - WebUI: remove "lodash" dep [[#6369](https://github.com/woodpecker-ci/woodpecker/pull/6369)] - Sanitize agent introduced pipeline/workflow/step state changes and log streaming [[#6308](https://github.com/woodpecker-ci/woodpecker/pull/6308)] - Send 404 if logs are not allowed to access [[#6349](https://github.com/woodpecker-ci/woodpecker/pull/6349)] - Prevent registering as arbitrary agents with system token [[#6283](https://github.com/woodpecker-ci/woodpecker/pull/6283)] - Update `fast-xml-parser` [[#6258](https://github.com/woodpecker-ci/woodpecker/pull/6258)] - Update `dompurify` and `svgo` [[#6198](https://github.com/woodpecker-ci/woodpecker/pull/6198)] - Update edwards25519 [[#6143](https://github.com/woodpecker-ci/woodpecker/pull/6143)] ### ✨ Features - Support one-shot agent execution mode [[#6150](https://github.com/woodpecker-ci/woodpecker/pull/6150)] - Add external secret extension implementation [[#6252](https://github.com/woodpecker-ci/woodpecker/pull/6252)] - Allow disabling isolated home directory for local agents [[#6251](https://github.com/woodpecker-ci/woodpecker/pull/6251)] - Add Container Registry credential extension [[#5993](https://github.com/woodpecker-ci/woodpecker/pull/5993)] - Support exclusive config extensions [[#5978](https://github.com/woodpecker-ci/woodpecker/pull/5978)] ### 📈 Enhancement - Kubernetes: precreate workingDir as nonroot when required [[#6322](https://github.com/woodpecker-ci/woodpecker/pull/6322)] - Kubernetes: Support allowPrivilegeEscalation and capabilities backend_options [[#6307](https://github.com/woodpecker-ci/woodpecker/pull/6307)] - Refactor: remove Auth() from Forge interface [[#6505](https://github.com/woodpecker-ci/woodpecker/pull/6505)] - Move wait for log uploads logic out of logger and tracer into pipeline runtime [[#6471](https://github.com/woodpecker-ci/woodpecker/pull/6471)] - Make agent reconnect retry timeout configurable [[#6470](https://github.com/woodpecker-ci/woodpecker/pull/6470)] - Handle re-created forge repos gracefully [[#6370](https://github.com/woodpecker-ci/woodpecker/pull/6370)] - Cleanup server store step interface [[#6476](https://github.com/woodpecker-ci/woodpecker/pull/6476)] - Docker/K8s: add config for stop timeout [[#6445](https://github.com/woodpecker-ci/woodpecker/pull/6445)] - Docker backend should retry to delete volume on "in use" error [[#6381](https://github.com/woodpecker-ci/woodpecker/pull/6381)] - Move skip pipeline by commit message into pipeline/frontend package [[#6437](https://github.com/woodpecker-ci/woodpecker/pull/6437)] - Init `server/scheduler` package and use it as proxy for queue & pubsub [[#6418](https://github.com/woodpecker-ci/woodpecker/pull/6418)] - Unify server API parameters to snake_case [[#6404](https://github.com/woodpecker-ci/woodpecker/pull/6404)] - Add netrc option for config/registry extension [[#6333](https://github.com/woodpecker-ci/woodpecker/pull/6333)] - Docker backend: replace docker SDK with moby SDK [[#6357](https://github.com/woodpecker-ci/woodpecker/pull/6357)] - Deprecate commit avatar envs [[#6356](https://github.com/woodpecker-ci/woodpecker/pull/6356)] - Refactor server/pubsub into interface [[#6318](https://github.com/woodpecker-ci/woodpecker/pull/6318)] - Separate cron field [[#6346](https://github.com/woodpecker-ci/woodpecker/pull/6346)] - Refactor pipeline runtime code [[#6166](https://github.com/woodpecker-ci/woodpecker/pull/6166)] - Show Woodpecker version on pipeline details [[#6316](https://github.com/woodpecker-ci/woodpecker/pull/6316)] - Unify import aliases [[#6328](https://github.com/woodpecker-ci/woodpecker/pull/6328)] - Improve linter warning when step has no when block [[#6314](https://github.com/woodpecker-ci/woodpecker/pull/6314)] - Improve error message when no workflows for manual were found [[#6313](https://github.com/woodpecker-ci/woodpecker/pull/6313)] - Server return conflict status when stale repo causes duplicate insert [[#6276](https://github.com/woodpecker-ci/woodpecker/pull/6276)] - Show global/org registries in org/repo registries tab [[#6291](https://github.com/woodpecker-ci/woodpecker/pull/6291)] - Report skipped step state as soon as it's determined [[#6295](https://github.com/woodpecker-ci/woodpecker/pull/6295)] - Only add compatibility environment variables for drone-ci to plugins [[#6271](https://github.com/woodpecker-ci/woodpecker/pull/6271)] - Refactor: pass backend explicitly when creating pipeline engine runtime [[#6268](https://github.com/woodpecker-ci/woodpecker/pull/6268)] - Compare admins case-insensitively [[#6261](https://github.com/woodpecker-ci/woodpecker/pull/6261)] - Allow to cancel on failure [[#6158](https://github.com/woodpecker-ci/woodpecker/pull/6158)] - Refactor so storage detects if Insert fails because of unique constraint [[#6259](https://github.com/woodpecker-ci/woodpecker/pull/6259)] - Add server config for maximum log lines shown in web UI [[#6250](https://github.com/woodpecker-ci/woodpecker/pull/6250)] - Add "Load more" pagination to pipeline list [[#6200](https://github.com/woodpecker-ci/woodpecker/pull/6200)] - Use upstream slices.Concat and remove utils.MergeSlices [[#6185](https://github.com/woodpecker-ci/woodpecker/pull/6185)] - Add enhanced function for error message handling in http request for configuration fetching [[#5712](https://github.com/woodpecker-ci/woodpecker/pull/5712)] - Remove fixed badge width in UI [[#6157](https://github.com/woodpecker-ci/woodpecker/pull/6157)] - Improve Debian packages [[#6085](https://github.com/woodpecker-ci/woodpecker/pull/6085)] - Refactor pipeline engine [[#6073](https://github.com/woodpecker-ci/woodpecker/pull/6073)] - Show cancellation reason in pipeline details [[#6072](https://github.com/woodpecker-ci/woodpecker/pull/6072)] - Document required forge methods [[#6049](https://github.com/woodpecker-ci/woodpecker/pull/6049)] - Dynamic log following [[#6036](https://github.com/woodpecker-ci/woodpecker/pull/6036)] - Per-Workflow and Per-Workflow-Step badge generation [[#5977](https://github.com/woodpecker-ci/woodpecker/pull/5977)] - Render MD in pipeline titles [[#5999](https://github.com/woodpecker-ci/woodpecker/pull/5999)] - Simplify and Fix server task queue [[#6017](https://github.com/woodpecker-ci/woodpecker/pull/6017)] - Update Architecture: move `pipeline/rpc` => `rpc` & `server/{grpc => rpc}` [[#6012](https://github.com/woodpecker-ci/woodpecker/pull/6012)] - Implement retry logic in HTTP Send method [[#5857](https://github.com/woodpecker-ci/woodpecker/pull/5857)] - CLI: Allow single output template [[#5882](https://github.com/woodpecker-ci/woodpecker/pull/5882)] - Improve service syntax related docs and tests nits [[#5991](https://github.com/woodpecker-ci/woodpecker/pull/5991)] - Remove deactivated secrets type from container definition [[#5983](https://github.com/woodpecker-ci/woodpecker/pull/5983)] ### 🐛 Bug Fixes - fix(web): escape HTML in commit messages to prevent XSS [[#6523](https://github.com/woodpecker-ci/woodpecker/pull/6523)] - fix(cli,server): fix trusted flags copy-paste bug and server nil pointer panic [[#6501](https://github.com/woodpecker-ci/woodpecker/pull/6501)] - Add refname to bitbucket commit status [[#6482](https://github.com/woodpecker-ci/woodpecker/pull/6482)] - Fix send on closed channel panic in SSE stream handlers [[#6456](https://github.com/woodpecker-ci/woodpecker/pull/6456)] - Add `WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE` config to preserve non-breaking behavior by default [[#6448](https://github.com/woodpecker-ci/woodpecker/pull/6448)] - Fix race in pipeline runtime [[#6451](https://github.com/woodpecker-ci/woodpecker/pull/6451)] - Fix race in server LogEntry logger [[#6449](https://github.com/woodpecker-ci/woodpecker/pull/6449)] - Kubernetes: detached steps are no services [[#6435](https://github.com/woodpecker-ci/woodpecker/pull/6435)] - Support dots in image names [[#6431](https://github.com/woodpecker-ci/woodpecker/pull/6431)] - Fix erroneous linter error for plugin privileges [[#6424](https://github.com/woodpecker-ci/woodpecker/pull/6424)] - Add connection timeout and graceful shutdown to agent RPC client [[#6414](https://github.com/woodpecker-ci/woodpecker/pull/6414)] - Fix Windows container exit code handling and error checks [[#6411](https://github.com/woodpecker-ci/woodpecker/pull/6411)] - Bitbucket: Remove usage of deprecated /user/permissions/repositories [[#6401](https://github.com/woodpecker-ci/woodpecker/pull/6401)] - Bitbucket: Fix parsing /user/workspaces response [[#6396](https://github.com/woodpecker-ci/woodpecker/pull/6396)] - Fix CLI exec with workflow matrix feature, where variables are not substituted. [[#6162](https://github.com/woodpecker-ci/woodpecker/pull/6162)] - Fix enable repo with same name and owner on second forge [[#6375](https://github.com/woodpecker-ci/woodpecker/pull/6375)] - Fix workflow being skipped and marked as failed when agent starts before server [[#6361](https://github.com/woodpecker-ci/woodpecker/pull/6361)] - Only redirect after login [[#6348](https://github.com/woodpecker-ci/woodpecker/pull/6348)] - Set workflow services stuck in running state to finished [[#6337](https://github.com/woodpecker-ci/woodpecker/pull/6337)] - Fix bitbucket api deprecations [[#6324](https://github.com/woodpecker-ci/woodpecker/pull/6324)] - Fix workflow serialize to omit skip_clone if false [[#6319](https://github.com/woodpecker-ci/woodpecker/pull/6319)] - Fix build deb rpm packages [[#6309](https://github.com/woodpecker-ci/woodpecker/pull/6309)] - Enable crons if created via CLI [[#6228](https://github.com/woodpecker-ci/woodpecker/pull/6228)] - Fix message on gitlab tag event [[#6196](https://github.com/woodpecker-ci/woodpecker/pull/6196)] - Bitbucket DC: resolve annotated tag SHA to commit SHA before posting build status [[#6203](https://github.com/woodpecker-ci/woodpecker/pull/6203)] - Prevent leaking goroutines on canceled steps [[#6186](https://github.com/woodpecker-ci/woodpecker/pull/6186)] - Fix `when.status` filter evaluation and add workflow-level support [[#6183](https://github.com/woodpecker-ci/woodpecker/pull/6183)] - Fix status merging with skipped pipelines [[#6176](https://github.com/woodpecker-ci/woodpecker/pull/6176)] - Update pipeline config schema [[#6156](https://github.com/woodpecker-ci/woodpecker/pull/6156)] - Fix OAuth token refresh race condition with singleflight [[#6153](https://github.com/woodpecker-ci/woodpecker/pull/6153)] - Use priority-based merging to determine pipeline and workflow status [[#6119](https://github.com/woodpecker-ci/woodpecker/pull/6119)] - Only set tag env on tags [[#6142](https://github.com/woodpecker-ci/woodpecker/pull/6142)] - Fix bitbucket email [[#6102](https://github.com/woodpecker-ci/woodpecker/pull/6102)] - Report status for detached steps and services [[#6039](https://github.com/woodpecker-ci/woodpecker/pull/6039)] - Don't propagate workflow error from agent back to agent [[#6056](https://github.com/woodpecker-ci/woodpecker/pull/6056)] - Fix pipeline cancellation status handling and step state synchronization [[#6011](https://github.com/woodpecker-ci/woodpecker/pull/6011)] - Add retry logic for CreatePipeline with backoff [[#6067](https://github.com/woodpecker-ci/woodpecker/pull/6067)] - Fix OAuth token refresh in webhook handling for Bitbucket and GitHub [[#6059](https://github.com/woodpecker-ci/woodpecker/pull/6059)] - Refresh token before forge calls [[#6035](https://github.com/woodpecker-ci/woodpecker/pull/6035)] - Local backend: cleanup generated script for cmd.exe shell [[#6029](https://github.com/woodpecker-ci/woodpecker/pull/6029)] - Local backend: setup clone step respects context [[#6030](https://github.com/woodpecker-ci/woodpecker/pull/6030)] - Fix: Agent now gracefully handles running containers when killed [[#6018](https://github.com/woodpecker-ci/woodpecker/pull/6018)] - Local backend: handle canceled steps case [[#6008](https://github.com/woodpecker-ci/woodpecker/pull/6008)] ### 🧪 Tests - e2e test wait for grpc server teardown and stop agents [[#6479](https://github.com/woodpecker-ci/woodpecker/pull/6479)] - Add more test cases for rpc label filter [[#6483](https://github.com/woodpecker-ci/woodpecker/pull/6483)] - Fix flaky TestJWTManager [[#6478](https://github.com/woodpecker-ci/woodpecker/pull/6478)] - Add e2e pipeline restart test [[#6469](https://github.com/woodpecker-ci/woodpecker/pull/6469)] - Init minimal e2e tests [[#6391](https://github.com/woodpecker-ci/woodpecker/pull/6391)] - Enhance datastore DB test setup [[#6450](https://github.com/woodpecker-ci/woodpecker/pull/6450)] - Dummy backend support cancel [[#6390](https://github.com/woodpecker-ci/woodpecker/pull/6390)] - Extend workflow integration tests [[#6272](https://github.com/woodpecker-ci/woodpecker/pull/6272)] - Add registry service tests [[#6330](https://github.com/woodpecker-ci/woodpecker/pull/6330)] - Add workflow integration test [[#6270](https://github.com/woodpecker-ci/woodpecker/pull/6270)] - Increase timeout for migration tests [[#6206](https://github.com/woodpecker-ci/woodpecker/pull/6206)] - Ignore fixtures for coverage [[#6197](https://github.com/woodpecker-ci/woodpecker/pull/6197)] - Use tabs for indentation in embedded JSON [[#6103](https://github.com/woodpecker-ci/woodpecker/pull/6103)] - Add tests for CLI output formatting and pipeline metadata environment variables [[#6076](https://github.com/woodpecker-ci/woodpecker/pull/6076)] - Ignore mocks for coverage [[#6074](https://github.com/woodpecker-ci/woodpecker/pull/6074)] ### 📚 Documentation - docs: better description for when.status filter [[#6517](https://github.com/woodpecker-ci/woodpecker/pull/6517)] - docs: Add woodpecker-shellcheck lint to awesome list [[#6521](https://github.com/woodpecker-ci/woodpecker/pull/6521)] - Lock file maintenance [[#6508](https://github.com/woodpecker-ci/woodpecker/pull/6508)] - Update docs npm deps non-major [[#6496](https://github.com/woodpecker-ci/woodpecker/pull/6496)] - Add Laravel Forge plugin [[#6491](https://github.com/woodpecker-ci/woodpecker/pull/6491)] - Add 'entrypoint' property to service in schema [[#6487](https://github.com/woodpecker-ci/woodpecker/pull/6487)] - Lock file maintenance [[#6472](https://github.com/woodpecker-ci/woodpecker/pull/6472)] - Update dependency axios to v1.15.1 [[#6468](https://github.com/woodpecker-ci/woodpecker/pull/6468)] - Update dependency marked to v18.0.2 [[#6465](https://github.com/woodpecker-ci/woodpecker/pull/6465)] - Update docs npm deps non-major [[#6463](https://github.com/woodpecker-ci/woodpecker/pull/6463)] - Update dependency marked to v18 [[#6425](https://github.com/woodpecker-ci/woodpecker/pull/6425)] - Update docs npm deps non-major [[#6422](https://github.com/woodpecker-ci/woodpecker/pull/6422)] - chore(deps): update dependency fuse.js to v7.3.0 [[#6382](https://github.com/woodpecker-ci/woodpecker/pull/6382)] - chore(deps): update docs npm deps non-major [[#6376](https://github.com/woodpecker-ci/woodpecker/pull/6376)] - chore(deps): update dependency typescript to v6 [[#6336](https://github.com/woodpecker-ci/woodpecker/pull/6336)] - chore(deps): update docs npm deps non-major [[#6335](https://github.com/woodpecker-ci/woodpecker/pull/6335)] - Add CI check for docs on feature PRs [[#6315](https://github.com/woodpecker-ci/woodpecker/pull/6315)] - chore(deps): update dependency isomorphic-dompurify to v3.6.0 [[#6288](https://github.com/woodpecker-ci/woodpecker/pull/6288)] - chore(deps): update dependency yaml to v2.8.3 [[#6287](https://github.com/woodpecker-ci/woodpecker/pull/6287)] - Add agentscan to plugin docs [[#6285](https://github.com/woodpecker-ci/woodpecker/pull/6285)] - Add opengrep plugin [[#6282](https://github.com/woodpecker-ci/woodpecker/pull/6282)] - chore(deps): update docs npm deps non-major [[#6281](https://github.com/woodpecker-ci/woodpecker/pull/6281)] - Sort glossary items alphabetically [[#6255](https://github.com/woodpecker-ci/woodpecker/pull/6255)] - chore(deps): update docs npm deps non-major [[#6240](https://github.com/woodpecker-ci/woodpecker/pull/6240)] - plugin: ascii junit report: renamed gh username [[#6232](https://github.com/woodpecker-ci/woodpecker/pull/6232)] - chore(deps): update dependency svgo to v4 [[#6214](https://github.com/woodpecker-ci/woodpecker/pull/6214)] - chore(deps): update docs npm deps non-major [[#6210](https://github.com/woodpecker-ci/woodpecker/pull/6210)] - Update serialize-javascript [[#6182](https://github.com/woodpecker-ci/woodpecker/pull/6182)] - chore(deps): update docs npm deps non-major [[#6173](https://github.com/woodpecker-ci/woodpecker/pull/6173)] - chore(deps): update dependency isomorphic-dompurify to v3 [[#6147](https://github.com/woodpecker-ci/woodpecker/pull/6147)] - chore(deps): update docs npm deps non-major [[#6137](https://github.com/woodpecker-ci/woodpecker/pull/6137)] - Add deprecation policy [[#6068](https://github.com/woodpecker-ci/woodpecker/pull/6068)] - fix(deps): update dependency @easyops-cn/docusaurus-search-local to ^0.55.0 [[#6125](https://github.com/woodpecker-ci/woodpecker/pull/6125)] - Improve selinux docs [[#6066](https://github.com/woodpecker-ci/woodpecker/pull/6066)] - Document how to ignore failure on services [[#6106](https://github.com/woodpecker-ci/woodpecker/pull/6106)] - chore(deps): update docs npm deps non-major [[#6109](https://github.com/woodpecker-ci/woodpecker/pull/6109)] - fix(deps): update dependency @easyops-cn/docusaurus-search-local to ^0.54.0 [[#6091](https://github.com/woodpecker-ci/woodpecker/pull/6091)] - chore(deps): update dependency axios to v1.13.5 [[#6090](https://github.com/woodpecker-ci/woodpecker/pull/6090)] - chore(deps): update docs npm deps non-major [[#6088](https://github.com/woodpecker-ci/woodpecker/pull/6088)] - chore(deps): update dependency isomorphic-dompurify to v2.36.0 [[#6086](https://github.com/woodpecker-ci/woodpecker/pull/6086)] - fix(deps): update docs npm deps non-major [[#6052](https://github.com/woodpecker-ci/woodpecker/pull/6052)] - Update Module Interaction Diagram [[#6019](https://github.com/woodpecker-ci/woodpecker/pull/6019)] - Add Buildah plugin link [[#6050](https://github.com/woodpecker-ci/woodpecker/pull/6050)] - chore(deps): update docs npm deps non-major [[#6045](https://github.com/woodpecker-ci/woodpecker/pull/6045)] - Add Homebrew package [[#6037](https://github.com/woodpecker-ci/woodpecker/pull/6037)] - chore(deps): update dependency axios to v1.13.3 [[#6010](https://github.com/woodpecker-ci/woodpecker/pull/6010)] - chore(deps): update docs npm deps non-major [[#6000](https://github.com/woodpecker-ci/woodpecker/pull/6000)] - Fix docusaurus md link deprecation [[#5979](https://github.com/woodpecker-ci/woodpecker/pull/5979)] - chore(deps): update docs npm deps non-major [[#5982](https://github.com/woodpecker-ci/woodpecker/pull/5982)] ### 📦️ Dependency - Update golang-packages [[#6524](https://github.com/woodpecker-ci/woodpecker/pull/6524)] - Update module github.com/google/go-github/v84 to v85 [[#6500](https://github.com/woodpecker-ci/woodpecker/pull/6500)] - Update module github.com/getkin/kin-openapi to v0.136.0 [[#6503](https://github.com/woodpecker-ci/woodpecker/pull/6503)] - Update woodpeckerci/plugin-git Docker tag to v2.9.0 [[#6499](https://github.com/woodpecker-ci/woodpecker/pull/6499)] - Update docker.io/mysql Docker tag to v9.7.0 [[#6498](https://github.com/woodpecker-ci/woodpecker/pull/6498)] - Update docker.io/lycheeverse/lychee Docker tag to v0.24.1 [[#6497](https://github.com/woodpecker-ci/woodpecker/pull/6497)] - Update golang-packages to v0.36.0 [[#6485](https://github.com/woodpecker-ci/woodpecker/pull/6485)] - Update golang-packages [[#6477](https://github.com/woodpecker-ci/woodpecker/pull/6477)] - Update pre-commit hook rbubley/mirrors-prettier to v3.8.3 [[#6462](https://github.com/woodpecker-ci/woodpecker/pull/6462)] - Update module k8s.io/client-go to v0.35.4 [[#6460](https://github.com/woodpecker-ci/woodpecker/pull/6460)] - Update golang-packages [[#6459](https://github.com/woodpecker-ci/woodpecker/pull/6459)] - Update docker.io/woodpeckerci/plugin-trivy Docker tag to v1.4.5 [[#6447](https://github.com/woodpecker-ci/woodpecker/pull/6447)] - Update docker.io/woodpeckerci/plugin-ready-release-go Docker tag to v4.1.1 [[#6440](https://github.com/woodpecker-ci/woodpecker/pull/6440)] - Update module gitlab.com/gitlab-org/api/client-go/v2 to v2.18.0 [[#6439](https://github.com/woodpecker-ci/woodpecker/pull/6439)] - Update docker.io/woodpeckerci/plugin-codecov Docker tag to v2.3.1 [[#6438](https://github.com/woodpecker-ci/woodpecker/pull/6438)] - Lock file maintenance [[#6430](https://github.com/woodpecker-ci/woodpecker/pull/6430)] - Update dependency dotenv to v17.4.2 [[#6428](https://github.com/woodpecker-ci/woodpecker/pull/6428)] - Update dependency simple-icons to v16.16.0 [[#6427](https://github.com/woodpecker-ci/woodpecker/pull/6427)] - Update web npm deps non-major [[#6423](https://github.com/woodpecker-ci/woodpecker/pull/6423)] - Update pre-commit hook rbubley/mirrors-prettier to v3.8.2 [[#6421](https://github.com/woodpecker-ci/woodpecker/pull/6421)] - Update dependency golang to v1.26.2 [[#6420](https://github.com/woodpecker-ci/woodpecker/pull/6420)] - fix(deps): update module github.com/docker/cli to v29.4.0+incompatible [[#6403](https://github.com/woodpecker-ci/woodpecker/pull/6403)] - fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.41 [[#6397](https://github.com/woodpecker-ci/woodpecker/pull/6397)] - chore(deps): lock file maintenance [[#6392](https://github.com/woodpecker-ci/woodpecker/pull/6392)] - chore(deps): update dependency dotenv to v17.4.1 [[#6389](https://github.com/woodpecker-ci/woodpecker/pull/6389)] - chore(deps): update dependency marked to v17.0.6 [[#6387](https://github.com/woodpecker-ci/woodpecker/pull/6387)] - chore(deps): update dependency simple-icons to v16.15.0 [[#6385](https://github.com/woodpecker-ci/woodpecker/pull/6385)] - fix(deps): update golang-packages [[#6384](https://github.com/woodpecker-ci/woodpecker/pull/6384)] - chore(deps): update dependency fuse.js to v7.3.0 [[#6383](https://github.com/woodpecker-ci/woodpecker/pull/6383)] - chore(deps): update dependency @antfu/eslint-config to v8 [[#6378](https://github.com/woodpecker-ci/woodpecker/pull/6378)] - chore(deps): update web npm deps non-major [[#6377](https://github.com/woodpecker-ci/woodpecker/pull/6377)] - fix(deps): update module github.com/lib/pq to v1.12.2 [[#6371](https://github.com/woodpecker-ci/woodpecker/pull/6371)] - fix(deps): update module google.golang.org/grpc to v1.80.0 [[#6363](https://github.com/woodpecker-ci/woodpecker/pull/6363)] - fix(deps): update golang-packages [[#6343](https://github.com/woodpecker-ci/woodpecker/pull/6343)] - chore(deps): lock file maintenance [[#6344](https://github.com/woodpecker-ci/woodpecker/pull/6344)] - chore(deps): update dependency simple-icons to v16.14.0 [[#6341](https://github.com/woodpecker-ci/woodpecker/pull/6341)] - chore(deps): update web npm deps non-major [[#6334](https://github.com/woodpecker-ci/woodpecker/pull/6334)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v4.1.0 [[#6331](https://github.com/woodpecker-ci/woodpecker/pull/6331)] - fix(deps): update module code.gitea.io/sdk/gitea to v0.24.1 [[#6321](https://github.com/woodpecker-ci/woodpecker/pull/6321)] - chore(deps): lock file maintenance [[#6306](https://github.com/woodpecker-ci/woodpecker/pull/6306)] - fix(deps): update module github.com/charmbracelet/huh to v2 [[#6243](https://github.com/woodpecker-ci/woodpecker/pull/6243)] - chore(deps): update dependency golangci/golangci-lint to v2.11.4 [[#6301](https://github.com/woodpecker-ci/woodpecker/pull/6301)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.11.4 [[#6302](https://github.com/woodpecker-ci/woodpecker/pull/6302)] - chore(deps): update web npm deps non-major [[#6279](https://github.com/woodpecker-ci/woodpecker/pull/6279)] - fix(deps): update module github.com/zalando/go-keyring to v0.2.7 [[#6280](https://github.com/woodpecker-ci/woodpecker/pull/6280)] - fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.37 [[#6253](https://github.com/woodpecker-ci/woodpecker/pull/6253)] - chore(deps): update dependency jsdom to v29 [[#6246](https://github.com/woodpecker-ci/woodpecker/pull/6246)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.3.0 [[#6241](https://github.com/woodpecker-ci/woodpecker/pull/6241)] - chore(deps): update dependency vite to v8 [[#6242](https://github.com/woodpecker-ci/woodpecker/pull/6242)] - chore(deps): update pre-commit non-major [[#6212](https://github.com/woodpecker-ci/woodpecker/pull/6212)] - chore(deps): update dependency vue-i18n to v11.3.0 [[#6217](https://github.com/woodpecker-ci/woodpecker/pull/6217)] - chore(deps): update dependency golang to v1.26.1 [[#6207](https://github.com/woodpecker-ci/woodpecker/pull/6207)] - fix(deps): update module github.com/docker/cli to v29.3.0+incompatible [[#6201](https://github.com/woodpecker-ci/woodpecker/pull/6201)] - fix(deps): update module github.com/yaronf/httpsign to v0.4.2 [[#6188](https://github.com/woodpecker-ci/woodpecker/pull/6188)] - chore(deps): update dependency eslint-plugin-vue-scoped-css to v3 [[#6178](https://github.com/woodpecker-ci/woodpecker/pull/6178)] - chore(deps): update dependency @intlify/eslint-plugin-vue-i18n to v4.3.0 [[#6177](https://github.com/woodpecker-ci/woodpecker/pull/6177)] - fix(deps): update module github.com/google/go-github/v83 to v84 [[#6172](https://github.com/woodpecker-ci/woodpecker/pull/6172)] - chore(deps): update postgres docker tag to v18.3 [[#6169](https://github.com/woodpecker-ci/woodpecker/pull/6169)] - fix(deps): update golang-packages [[#6160](https://github.com/woodpecker-ci/woodpecker/pull/6160)] - chore(deps): update dependency vue-tsc to v3.2.5 [[#6141](https://github.com/woodpecker-ci/woodpecker/pull/6141)] - chore(deps): update docker.io/golang docker tag to v1.26 [[#6121](https://github.com/woodpecker-ci/woodpecker/pull/6121)] - chore(deps): update docker.io/lycheeverse/lychee docker tag to v0.23.0 [[#6122](https://github.com/woodpecker-ci/woodpecker/pull/6122)] - chore(deps): update dependency @types/node to v24.10.12 [[#6087](https://github.com/woodpecker-ci/woodpecker/pull/6087)] - chore(deps): update eslint monorepo to v10 (major) [[#6083](https://github.com/woodpecker-ci/woodpecker/pull/6083)] - chore(deps): update dependency @antfu/eslint-config to v7.3.0 [[#6084](https://github.com/woodpecker-ci/woodpecker/pull/6084)] - chore(deps): update dependency @vueuse/core to v14.2.0 [[#6048](https://github.com/woodpecker-ci/woodpecker/pull/6048)] - fix(deps): update dependency vue-router to v5 [[#6046](https://github.com/woodpecker-ci/woodpecker/pull/6046)] - chore(deps): update woodpeckerci/plugin-git docker tag to v2.8.1 [[#6006](https://github.com/woodpecker-ci/woodpecker/pull/6006)] - chore(deps): update docker.io/mysql docker tag to v9.6.0 [[#6002](https://github.com/woodpecker-ci/woodpecker/pull/6002)] - fix(deps): update module github.com/urfave/cli/v3 to v3.6.2 [[#5989](https://github.com/woodpecker-ci/woodpecker/pull/5989)] ### Misc - Add s3 cache plugin to docs [[#6467](https://github.com/woodpecker-ci/woodpecker/pull/6467)] - Fix license headers [[#6205](https://github.com/woodpecker-ci/woodpecker/pull/6205)] - Add agentscan plugin [[#6284](https://github.com/woodpecker-ci/woodpecker/pull/6284)] ## [3.13.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.13.0) - 2026-01-14 ### ❤️ Thanks to all contributors! ❤️ @6543, @Javex, @KhalidAlansary, @MartinSchmidt, @abhiyerra, @anbraten, @bentasker, @gjuoun, @gsaslis, @henkka, @jolheiser, @mogsie, @qwerty287, @sloonz, @sugar700, @tuxmainy, @xoxys ### 🔒 Security - Update quic-go/qpack & quic-go/quic-go [[#5885](https://github.com/woodpecker-ci/woodpecker/pull/5885)] - fix: updateRepoPermissions to cleanup old permissions [[#5790](https://github.com/woodpecker-ci/woodpecker/pull/5790)] ### ✨ Features - Add cli contexts [[#5929](https://github.com/woodpecker-ci/woodpecker/pull/5929)] ### 📈 Enhancement - Allow to add a note to secrets [[#5898](https://github.com/woodpecker-ci/woodpecker/pull/5898)] - Log addon errors [[#5923](https://github.com/woodpecker-ci/woodpecker/pull/5923)] - Custom vars for crons [[#5897](https://github.com/woodpecker-ci/woodpecker/pull/5897)] - Allow to disable a cron [[#5896](https://github.com/woodpecker-ci/woodpecker/pull/5896)] - Add background to status icons [[#5880](https://github.com/woodpecker-ci/woodpecker/pull/5880)] - Fix dead page and cleanup router [[#5519](https://github.com/woodpecker-ci/woodpecker/pull/5519)] - feat(kubernetes): add support for pod affinity and anti-affinity configurations [[#5854](https://github.com/woodpecker-ci/woodpecker/pull/5854)] - Public key endpoint [[#5860](https://github.com/woodpecker-ci/woodpecker/pull/5860)] - Allow untrusted repo to still drop network for steps [[#5820](https://github.com/woodpecker-ci/woodpecker/pull/5820)] - Add support for headless Kubernetes services [[#5764](https://github.com/woodpecker-ci/woodpecker/pull/5764)] - server/forge: rename var to be more descriptive and test value [[#5806](https://github.com/woodpecker-ci/woodpecker/pull/5806)] - add events query parameter to badge url [[#5728](https://github.com/woodpecker-ci/woodpecker/pull/5728)] - Extract default step-builder options into server [[#5785](https://github.com/woodpecker-ci/woodpecker/pull/5785)] - feat: include CI_COMMIT_TAG env in deployment events [[#5773](https://github.com/woodpecker-ci/woodpecker/pull/5773)] ### 🐛 Bug Fixes - Use repo-user for api call of cron [[#5967](https://github.com/woodpecker-ci/woodpecker/pull/5967)] - Close opened file on LogFind [[#5961](https://github.com/woodpecker-ci/woodpecker/pull/5961)] - Delete/Deactivate repo ignores missing repo at forge [[#5953](https://github.com/woodpecker-ci/woodpecker/pull/5953)] - Correctly update repo permissions [[#5928](https://github.com/woodpecker-ci/woodpecker/pull/5928)] - Revert repos pagination for GH and BB [[#5924](https://github.com/woodpecker-ci/woodpecker/pull/5924)] - fix: send correct argument to rpc call for name/url [[#5922](https://github.com/woodpecker-ci/woodpecker/pull/5922)] - fix: secrets-file flag [[#5909](https://github.com/woodpecker-ci/woodpecker/pull/5909)] - Do not run crons for disabled repos [[#5884](https://github.com/woodpecker-ci/woodpecker/pull/5884)] - Show warning if there is no workflow to run [[#5883](https://github.com/woodpecker-ci/woodpecker/pull/5883)] - fix(datastore): fix pagination bug in workflowsDelete skipping records [[#5881](https://github.com/woodpecker-ci/woodpecker/pull/5881)] - Remove rounded corners in fullscreen log view [[#5879](https://github.com/woodpecker-ci/woodpecker/pull/5879)] - Fix some ListItems and Queue view background in dark mode [[#5878](https://github.com/woodpecker-ci/woodpecker/pull/5878)] - Make disabled checkboxes match overall style [[#5869](https://github.com/woodpecker-ci/woodpecker/pull/5869)] - Fix CLI trusted updating [[#5861](https://github.com/woodpecker-ci/woodpecker/pull/5861)] - Send configuration as part of the request for external configuration [[#5831](https://github.com/woodpecker-ci/woodpecker/pull/5831)] - fix(bitbucketdatacenter): fix CI_COMMIT_PULL_REQUEST [[#5769](https://github.com/woodpecker-ci/woodpecker/pull/5769)] - On set/get of repo make sure forge_id is set and on fetch respected [[#5717](https://github.com/woodpecker-ci/woodpecker/pull/5717)] - Improve repair endpoints [[#5767](https://github.com/woodpecker-ci/woodpecker/pull/5767)] ### 📚 Documentation - chore(deps): lock file maintenance [[#5963](https://github.com/woodpecker-ci/woodpecker/pull/5963)] - chore(deps): update dependency @types/node to v24.10.7 [[#5954](https://github.com/woodpecker-ci/woodpecker/pull/5954)] - chore(deps): update dependency @types/react to v19.2.8 [[#5941](https://github.com/woodpecker-ci/woodpecker/pull/5941)] - chore(deps): update dependency @types/node to v24.10.6 [[#5935](https://github.com/woodpecker-ci/woodpecker/pull/5935)] - chore(deps): update dependency @types/node to v24.10.5 [[#5933](https://github.com/woodpecker-ci/woodpecker/pull/5933)] - fix(docs): update woodpecker-cli secret command [[#5927](https://github.com/woodpecker-ci/woodpecker/pull/5927)] - Update Docs and nix-flake to reflect current dev environment [[#5926](https://github.com/woodpecker-ci/woodpecker/pull/5926)] - Update Helm chart installation command [[#5872](https://github.com/woodpecker-ci/woodpecker/pull/5872)] - docs: add BunnyCDN Cache Purge Plugin [[#5906](https://github.com/woodpecker-ci/woodpecker/pull/5906)] - chore(deps): update dependency isomorphic-dompurify to v2.35.0 [[#5904](https://github.com/woodpecker-ci/woodpecker/pull/5904)] - chore(deps): update dependency @types/node to v24.10.4 [[#5862](https://github.com/woodpecker-ci/woodpecker/pull/5862)] - chore(deps): update docs npm deps non-major [[#5855](https://github.com/woodpecker-ci/woodpecker/pull/5855)] - chore(deps): update docs npm deps non-major [[#5829](https://github.com/woodpecker-ci/woodpecker/pull/5829)] - Update link for Codeberg Pages Deploy plugin [[#5811](https://github.com/woodpecker-ci/woodpecker/pull/5811)] - chore(deps): update dependency yaml to v2.8.2 [[#5803](https://github.com/woodpecker-ci/woodpecker/pull/5803)] - chore(deps): update dependency prettier to v3.7.3 [[#5799](https://github.com/woodpecker-ci/woodpecker/pull/5799)] - chore(deps): update docs npm deps non-major [[#5791](https://github.com/woodpecker-ci/woodpecker/pull/5791)] - chore(deps): update dependency isomorphic-dompurify to v2.33.0 [[#5778](https://github.com/woodpecker-ci/woodpecker/pull/5778)] - chore(deps): update docs npm deps non-major [[#5774](https://github.com/woodpecker-ci/woodpecker/pull/5774)] ### 📦️ Dependency - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.14.0 [[#5969](https://github.com/woodpecker-ci/woodpecker/pull/5969)] - fix(deps): update golang-packages [[#5966](https://github.com/woodpecker-ci/woodpecker/pull/5966)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.12.0 [[#5962](https://github.com/woodpecker-ci/woodpecker/pull/5962)] - chore(deps): update dependency simple-icons to v16.5.0 [[#5957](https://github.com/woodpecker-ci/woodpecker/pull/5957)] - fix(deps): update golang-packages [[#5956](https://github.com/woodpecker-ci/woodpecker/pull/5956)] - chore(deps): update dependency @types/node to v24.10.7 [[#5955](https://github.com/woodpecker-ci/woodpecker/pull/5955)] - fix(deps): update module github.com/google/go-github/v80 to v81 [[#5946](https://github.com/woodpecker-ci/woodpecker/pull/5946)] - chore(deps): update woodpeckerci/plugin-git docker tag to v2.8.0 [[#5945](https://github.com/woodpecker-ci/woodpecker/pull/5945)] - chore(deps): update golangci/golangci-lint docker tag to v2.8.0 [[#5944](https://github.com/woodpecker-ci/woodpecker/pull/5944)] - chore(deps): update docker.io/woodpeckerci/plugin-codecov docker tag to v2.2.0 [[#5943](https://github.com/woodpecker-ci/woodpecker/pull/5943)] - chore(deps): update web npm deps non-major [[#5942](https://github.com/woodpecker-ci/woodpecker/pull/5942)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.4.2 [[#5938](https://github.com/woodpecker-ci/woodpecker/pull/5938)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.4.1 [[#5937](https://github.com/woodpecker-ci/woodpecker/pull/5937)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.4 [[#5936](https://github.com/woodpecker-ci/woodpecker/pull/5936)] - chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.3 [[#5934](https://github.com/woodpecker-ci/woodpecker/pull/5934)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.11.0 [[#5919](https://github.com/woodpecker-ci/woodpecker/pull/5919)] - chore(deps): lock file maintenance [[#5916](https://github.com/woodpecker-ci/woodpecker/pull/5916)] - chore(deps): update dependency simple-icons to v16.4.0 [[#5915](https://github.com/woodpecker-ci/woodpecker/pull/5915)] - fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.33 [[#5910](https://github.com/woodpecker-ci/woodpecker/pull/5910)] - chore(deps): lock file maintenance [[#5913](https://github.com/woodpecker-ci/woodpecker/pull/5913)] - chore(deps): lock file maintenance [[#5907](https://github.com/woodpecker-ci/woodpecker/pull/5907)] - chore(deps): update dependency simple-icons to v16.3.0 [[#5905](https://github.com/woodpecker-ci/woodpecker/pull/5905)] - chore(deps): update web npm deps non-major [[#5903](https://github.com/woodpecker-ci/woodpecker/pull/5903)] - fix(deps): update module google.golang.org/grpc to v1.78.0 [[#5901](https://github.com/woodpecker-ci/woodpecker/pull/5901)] - chore(deps): lock file maintenance [[#5895](https://github.com/woodpecker-ci/woodpecker/pull/5895)] - fix(deps): update module github.com/tink-crypto/tink-go/v2 to v2.6.0 [[#5894](https://github.com/woodpecker-ci/woodpecker/pull/5894)] - chore(deps): update dependency @antfu/eslint-config to v6.7.2 [[#5893](https://github.com/woodpecker-ci/woodpecker/pull/5893)] - chore(deps): update dependency vue-i18n to v11.2.7 [[#5892](https://github.com/woodpecker-ci/woodpecker/pull/5892)] - chore(deps): update dependency vue-tsc to v3.2.0 [[#5891](https://github.com/woodpecker-ci/woodpecker/pull/5891)] - Migrate to maintained tink-go [[#5886](https://github.com/woodpecker-ci/woodpecker/pull/5886)] - chore(deps): update web npm deps non-major [[#5887](https://github.com/woodpecker-ci/woodpecker/pull/5887)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.10.0 [[#5888](https://github.com/woodpecker-ci/woodpecker/pull/5888)] - fix(deps): update golang-packages [[#5877](https://github.com/woodpecker-ci/woodpecker/pull/5877)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.9.0 [[#5873](https://github.com/woodpecker-ci/woodpecker/pull/5873)] - fix(deps): update golang-packages [[#5870](https://github.com/woodpecker-ci/woodpecker/pull/5870)] - chore(deps): lock file maintenance [[#5868](https://github.com/woodpecker-ci/woodpecker/pull/5868)] - fix(deps): update module github.com/gdgvda/cron to v0.6.0 [[#5867](https://github.com/woodpecker-ci/woodpecker/pull/5867)] - chore(deps): update dependency @intlify/unplugin-vue-i18n to v11.0.3 [[#5866](https://github.com/woodpecker-ci/woodpecker/pull/5866)] - chore(deps): update dependency @antfu/eslint-config to v6.7.1 [[#5865](https://github.com/woodpecker-ci/woodpecker/pull/5865)] - chore(deps): update web npm deps non-major [[#5864](https://github.com/woodpecker-ci/woodpecker/pull/5864)] - chore(deps): update dependency @types/node to v24.10.4 [[#5863](https://github.com/woodpecker-ci/woodpecker/pull/5863)] - chore(deps): update web npm deps non-major [[#5859](https://github.com/woodpecker-ci/woodpecker/pull/5859)] - chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.47.0 [[#5858](https://github.com/woodpecker-ci/woodpecker/pull/5858)] - fix(deps): update golang-packages [[#5856](https://github.com/woodpecker-ci/woodpecker/pull/5856)] - fix(deps): update golang-packages [[#5851](https://github.com/woodpecker-ci/woodpecker/pull/5851)] - fix(deps): update golang-packages [[#5849](https://github.com/woodpecker-ci/woodpecker/pull/5849)] - chore(deps): lock file maintenance [[#5847](https://github.com/woodpecker-ci/woodpecker/pull/5847)] - chore(deps): update web npm deps non-major [[#5837](https://github.com/woodpecker-ci/woodpecker/pull/5837)] - chore(deps): update dependency golangci/golangci-lint to v2.7.2 [[#5845](https://github.com/woodpecker-ci/woodpecker/pull/5845)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.7.2 [[#5846](https://github.com/woodpecker-ci/woodpecker/pull/5846)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.7.0 [[#5840](https://github.com/woodpecker-ci/woodpecker/pull/5840)] - fix(deps): update module github.com/google/go-github/v79 to v80 [[#5838](https://github.com/woodpecker-ci/woodpecker/pull/5838)] - chore(deps): update pre-commit non-major [[#5836](https://github.com/woodpecker-ci/woodpecker/pull/5836)] - chore(deps): update docker.io/lycheeverse/lychee docker tag to v0.22.0 [[#5833](https://github.com/woodpecker-ci/woodpecker/pull/5833)] - chore(deps): update dependency golangci/golangci-lint to v2.7.1 [[#5832](https://github.com/woodpecker-ci/woodpecker/pull/5832)] - chore(deps): update docker.io/alpine docker tag to v3.23 [[#5830](https://github.com/woodpecker-ci/woodpecker/pull/5830)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.4 [[#5828](https://github.com/woodpecker-ci/woodpecker/pull/5828)] - chore(deps): update dependency golang to v1.25.5 [[#5827](https://github.com/woodpecker-ci/woodpecker/pull/5827)] - fix(deps): update golang-packages [[#5816](https://github.com/woodpecker-ci/woodpecker/pull/5816)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.3.1 [[#5812](https://github.com/woodpecker-ci/woodpecker/pull/5812)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1.3.0 [[#5807](https://github.com/woodpecker-ci/woodpecker/pull/5807)] - chore(deps): lock file maintenance [[#5808](https://github.com/woodpecker-ci/woodpecker/pull/5808)] - chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.7.3 [[#5804](https://github.com/woodpecker-ci/woodpecker/pull/5804)] - fix(deps): update dependency simple-icons to v16 [[#5802](https://github.com/woodpecker-ci/woodpecker/pull/5802)] - fix(deps): update module github.com/docker/cli to v29.1.1+incompatible [[#5801](https://github.com/woodpecker-ci/woodpecker/pull/5801)] - chore(deps): update dependency prettier to v3.7.3 [[#5800](https://github.com/woodpecker-ci/woodpecker/pull/5800)] - chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.7.2 [[#5795](https://github.com/woodpecker-ci/woodpecker/pull/5795)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v1 [[#5794](https://github.com/woodpecker-ci/woodpecker/pull/5794)] - chore(deps): update web npm deps non-major [[#5792](https://github.com/woodpecker-ci/woodpecker/pull/5792)] - chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.7.1 [[#5793](https://github.com/woodpecker-ci/woodpecker/pull/5793)] - fix(deps): update module github.com/docker/cli to v29.1.0+incompatible [[#5789](https://github.com/woodpecker-ci/woodpecker/pull/5789)] - fix(deps): update golang-packages [[#5787](https://github.com/woodpecker-ci/woodpecker/pull/5787)] - chore(deps): lock file maintenance [[#5784](https://github.com/woodpecker-ci/woodpecker/pull/5784)] - chore(deps): update dependency simple-icons to v15.22.0 [[#5782](https://github.com/woodpecker-ci/woodpecker/pull/5782)] - chore(deps): update dependency vue-tsc to v3.1.5 [[#5781](https://github.com/woodpecker-ci/woodpecker/pull/5781)] - chore(deps): update dependency @types/lodash to v4.17.21 [[#5780](https://github.com/woodpecker-ci/woodpecker/pull/5780)] - chore(deps): update dependency vue-i18n to v11.2.1 [[#5779](https://github.com/woodpecker-ci/woodpecker/pull/5779)] - chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.46.0 [[#5776](https://github.com/woodpecker-ci/woodpecker/pull/5776)] - chore(deps): update web npm deps non-major [[#5775](https://github.com/woodpecker-ci/woodpecker/pull/5775)] - fix(deps): update golang-packages [[#5770](https://github.com/woodpecker-ci/woodpecker/pull/5770)] - fix(deps): update golang-packages [[#5765](https://github.com/woodpecker-ci/woodpecker/pull/5765)] ### Misc - Revert "Send configuration as part of the request for external configuration" [[#5835](https://github.com/woodpecker-ci/woodpecker/pull/5835)] - Allow packagers to set WebUI root to custom path [[#5809](https://github.com/woodpecker-ci/woodpecker/pull/5809)] - fix(queue): force agent cancellation on lease expiration [[#5823](https://github.com/woodpecker-ci/woodpecker/pull/5823)] - Extract interval into composition [[#5818](https://github.com/woodpecker-ci/woodpecker/pull/5818)] - Fix outdated Makefile target [[#5817](https://github.com/woodpecker-ci/woodpecker/pull/5817)] - Makefile: add target to generate man pages [[#5810](https://github.com/woodpecker-ci/woodpecker/pull/5810)] - Split make install targets [[#5796](https://github.com/woodpecker-ci/woodpecker/pull/5796)] - Use golangci docker image [[#5797](https://github.com/woodpecker-ci/woodpecker/pull/5797)] - Clarify envvars documentation [[#5788](https://github.com/woodpecker-ci/woodpecker/pull/5788)] ## [3.12.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.12.0) - 2025-11-18 ### ❤️ Thanks to all contributors! ❤️ @1001Josias, @6543, @JohnWalkerx, @LUKIEYF, @MeurillonGuillaume, @Utkarsh9571, @Xuxe, @anbraten, @chamburr, @henkka, @hhamalai, @marcusramberg, @pixelateapotato, @qwerty287, @yyewolf ### 🔒 Security - chore(deps): update dependency vite to v7.1.11 [security] [[#5660](https://github.com/woodpecker-ci/woodpecker/pull/5660)] ### 📈 Enhancement - feat(bitbucketserver): get changes from all commits in a single push event [[#5748](https://github.com/woodpecker-ci/woodpecker/pull/5748)] - Support for file changes in Bitbucket Cloud [[#5730](https://github.com/woodpecker-ci/woodpecker/pull/5730)] - feat(agent): log agent version on startup [[#5724](https://github.com/woodpecker-ci/woodpecker/pull/5724)] - Add Header User-Agent for request client [[#5664](https://github.com/woodpecker-ci/woodpecker/pull/5664)] - Switch from BoolTrue to optional.Option[bool] [[#5693](https://github.com/woodpecker-ci/woodpecker/pull/5693)] - Enhancement log stream reading and writing and handle new lines and max-size [[#5683](https://github.com/woodpecker-ci/woodpecker/pull/5683)] - Make local backend work with `cli exec` [[#4102](https://github.com/woodpecker-ci/woodpecker/pull/4102)] - Make pipeline/frontend/yaml/* types able to be marshaled back to YAML [[#1835](https://github.com/woodpecker-ci/woodpecker/pull/1835)] - Add log service addon [[#5507](https://github.com/woodpecker-ci/woodpecker/pull/5507)] - Support multiple users with same login name but different forges [[#5612](https://github.com/woodpecker-ci/woodpecker/pull/5612)] - Release linux/riscv64 binaries [[#5663](https://github.com/woodpecker-ci/woodpecker/pull/5663)] ### 🐛 Bug Fixes - Fix crash when a HTTP/2 client goes away on SSE streams [[#5738](https://github.com/woodpecker-ci/woodpecker/pull/5738)] - Add created icon [[#5747](https://github.com/woodpecker-ci/woodpecker/pull/5747)] - Fix badge label padding [[#5725](https://github.com/woodpecker-ci/woodpecker/pull/5725)] - Fix workflow path filter for GitHub [[#5721](https://github.com/woodpecker-ci/woodpecker/pull/5721)] - Fix secret on new forge [[#5715](https://github.com/woodpecker-ci/woodpecker/pull/5715)] - Revert to forge internal implementation of pagination for `Repos()` and `Teams()` for gitea/forgejo [[#5679](https://github.com/woodpecker-ci/woodpecker/pull/5679)] - fix: panic due to an invalid memory address when injectSecretRecursive encounters nil values [[#5699](https://github.com/woodpecker-ci/woodpecker/pull/5699)] - Fix so agents don't need to specify a required label twice [[#5684](https://github.com/woodpecker-ci/woodpecker/pull/5684)] - Fix nil pointer dereference during GitHub Hook parsing [[#5681](https://github.com/woodpecker-ci/woodpecker/pull/5681)] - Allow username to be used with multiple forges [[#5676](https://github.com/woodpecker-ci/woodpecker/pull/5676)] - Create GitHub forge via WebUI fails to be loaded [[#5675](https://github.com/woodpecker-ci/woodpecker/pull/5675)] - Bitbucket: ignore push hooks with no changes propperly [[#5672](https://github.com/woodpecker-ci/woodpecker/pull/5672)] - fix(bitbucketdatacenter): prevent adding new repos with empty branch [[#5669](https://github.com/woodpecker-ci/woodpecker/pull/5669)] - cli: show description of default value for `--backend-local-temp-dir` instead of value [[#5656](https://github.com/woodpecker-ci/woodpecker/pull/5656)] ### 📚 Documentation - Add docs for 3.12 [[#5763](https://github.com/woodpecker-ci/woodpecker/pull/5763)] - chore(deps): lock file maintenance [[#5760](https://github.com/woodpecker-ci/woodpecker/pull/5760)] - chore(deps): update docs npm deps non-major [[#5752](https://github.com/woodpecker-ci/woodpecker/pull/5752)] - chore(deps): update docs npm deps non-major [[#5733](https://github.com/woodpecker-ci/woodpecker/pull/5733)] - Fix typo in about.md [[#5716](https://github.com/woodpecker-ci/woodpecker/pull/5716)] - docs: add warning about 27-axis matrix limit [[#5700](https://github.com/woodpecker-ci/woodpecker/pull/5700)] - chore(deps): update dependency isomorphic-dompurify to v2.31.0 [[#5709](https://github.com/woodpecker-ci/woodpecker/pull/5709)] - chore(deps): update dependency @types/node to v24 [[#5706](https://github.com/woodpecker-ci/woodpecker/pull/5706)] - chore(deps): update docs npm deps non-major [[#5701](https://github.com/woodpecker-ci/woodpecker/pull/5701)] - Update path to plugins moved to woodpecker-community [[#5698](https://github.com/woodpecker-ci/woodpecker/pull/5698)] - chore(deps): update docs npm deps non-major [[#5688](https://github.com/woodpecker-ci/woodpecker/pull/5688)] - docs(plugins): add github-app-token and github-comment plugins to repository [[#5671](https://github.com/woodpecker-ci/woodpecker/pull/5671)] ### 📦️ Dependency - fix(deps): update module github.com/urfave/cli/v3 to v3.6.1 [[#5759](https://github.com/woodpecker-ci/woodpecker/pull/5759)] - chore(deps): update dependency vue-tsc to v3.1.4 [[#5758](https://github.com/woodpecker-ci/woodpecker/pull/5758)] - fix(deps): update module github.com/google/go-github/v78 to v79 [[#5757](https://github.com/woodpecker-ci/woodpecker/pull/5757)] - fix(deps): update module github.com/docker/cli to v29 [[#5756](https://github.com/woodpecker-ci/woodpecker/pull/5756)] - chore(deps): update postgres docker tag to v18.1 [[#5755](https://github.com/woodpecker-ci/woodpecker/pull/5755)] - chore(deps): update web npm deps non-major [[#5754](https://github.com/woodpecker-ci/woodpecker/pull/5754)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.2 [[#5753](https://github.com/woodpecker-ci/woodpecker/pull/5753)] - chore(deps): update dependency golangci/golangci-lint to v2.6.2 [[#5751](https://github.com/woodpecker-ci/woodpecker/pull/5751)] - fix(deps): update golang-packages [[#5746](https://github.com/woodpecker-ci/woodpecker/pull/5746)] - fix(deps): update golang-packages [[#5745](https://github.com/woodpecker-ci/woodpecker/pull/5745)] - fix(deps): update module github.com/urfave/cli/v3 to v3.6.0 [[#5743](https://github.com/woodpecker-ci/woodpecker/pull/5743)] - chore(deps): lock file maintenance [[#5744](https://github.com/woodpecker-ci/woodpecker/pull/5744)] - fix(deps): update golang-packages [[#5741](https://github.com/woodpecker-ci/woodpecker/pull/5741)] - chore(deps): update dependency simple-icons to v15.20.0 [[#5742](https://github.com/woodpecker-ci/woodpecker/pull/5742)] - fix(deps): update module github.com/google/go-github/v77 to v78 [[#5739](https://github.com/woodpecker-ci/woodpecker/pull/5739)] - fix(deps): update module github.com/google/go-github/v76 to v77 [[#5737](https://github.com/woodpecker-ci/woodpecker/pull/5737)] - fix(deps): update dependency marked to v17 [[#5736](https://github.com/woodpecker-ci/woodpecker/pull/5736)] - chore(deps): update web npm deps non-major [[#5735](https://github.com/woodpecker-ci/woodpecker/pull/5735)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.1 [[#5734](https://github.com/woodpecker-ci/woodpecker/pull/5734)] - chore(deps): update dependency golangci/golangci-lint to v2.6.1 [[#5732](https://github.com/woodpecker-ci/woodpecker/pull/5732)] - chore(deps): update dependency golang to v1.25.4 [[#5731](https://github.com/woodpecker-ci/woodpecker/pull/5731)] - fix(deps): update golang-packages to v28.5.2+incompatible [[#5723](https://github.com/woodpecker-ci/woodpecker/pull/5723)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.159.0 [[#5720](https://github.com/woodpecker-ci/woodpecker/pull/5720)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.158.0 [[#5718](https://github.com/woodpecker-ci/woodpecker/pull/5718)] - chore(deps): lock file maintenance [[#5711](https://github.com/woodpecker-ci/woodpecker/pull/5711)] - chore(deps): update dependency golangci/golangci-lint to v2.6.0 [[#5702](https://github.com/woodpecker-ci/woodpecker/pull/5702)] - chore(deps): update web npm deps non-major [[#5705](https://github.com/woodpecker-ci/woodpecker/pull/5705)] - fix(deps): update module github.com/yaronf/httpsign to v0.4.1 [[#5708](https://github.com/woodpecker-ci/woodpecker/pull/5708)] - chore(deps): update node.js to v24 [[#5707](https://github.com/woodpecker-ci/woodpecker/pull/5707)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.0 [[#5704](https://github.com/woodpecker-ci/woodpecker/pull/5704)] - chore(deps): update gitea/gitea docker tag to v1.25 [[#5703](https://github.com/woodpecker-ci/woodpecker/pull/5703)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.157.1 [[#5697](https://github.com/woodpecker-ci/woodpecker/pull/5697)] - chore(deps): lock file maintenance [[#5695](https://github.com/woodpecker-ci/woodpecker/pull/5695)] - chore(deps): update web npm deps non-major [[#5694](https://github.com/woodpecker-ci/woodpecker/pull/5694)] - fix(deps): update dependency @vueuse/core to v14 [[#5692](https://github.com/woodpecker-ci/woodpecker/pull/5692)] - chore(deps): update dependency vitest to v4 [[#5691](https://github.com/woodpecker-ci/woodpecker/pull/5691)] - chore(deps): update docker.io/mysql docker tag to v9.5.0 [[#5690](https://github.com/woodpecker-ci/woodpecker/pull/5690)] - chore(deps): update web npm deps non-major [[#5689](https://github.com/woodpecker-ci/woodpecker/pull/5689)] - chore(deps): update dependency mvdan/gofumpt to v0.9.2 [[#5687](https://github.com/woodpecker-ci/woodpecker/pull/5687)] - fix(deps): update github.com/urfave/cli-docs/v3 digest to 72b87d1 [[#5686](https://github.com/woodpecker-ci/woodpecker/pull/5686)] - fix(deps): update module code.gitea.io/sdk/gitea to v0.22.1 [[#5682](https://github.com/woodpecker-ci/woodpecker/pull/5682)] - fix(deps): update module github.com/urfave/cli/v3 to v3.5.0 [[#5668](https://github.com/woodpecker-ci/woodpecker/pull/5668)] - fix(deps): update module xorm.io/xorm to v1.3.11 [[#5662](https://github.com/woodpecker-ci/woodpecker/pull/5662)] - chore(deps): lock file maintenance [[#5657](https://github.com/woodpecker-ci/woodpecker/pull/5657)] ### Misc - Also create image preview on label change only [[#5673](https://github.com/woodpecker-ci/woodpecker/pull/5673)] - Add migration tests for postgres [[#669](https://github.com/woodpecker-ci/woodpecker/pull/669)] ## [3.11.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.11.0) - 2025-10-19 ### ❤️ Thanks to all contributors! ❤️ @6543, @Gusted, @MartinSchmidt, @anbraten, @eikemeier, @henkka, @joariasl, @marcusramberg, @qwerty287, @xoxys ### ✨ Features - Allow to configure a config extension per repo [[#3349](https://github.com/woodpecker-ci/woodpecker/pull/3349)] ### 📈 Enhancement - Improve log.CopyByLine to be more robust [[#5641](https://github.com/woodpecker-ci/woodpecker/pull/5641)] - Add pagination for `Repos()` and `Teams()` in Forge interface [[#5638](https://github.com/woodpecker-ci/woodpecker/pull/5638)] - Modernize a couple of loops, fix incorrect function docs [[#5637](https://github.com/woodpecker-ci/woodpecker/pull/5637)] - Allow agents to require labels on workflows [[#5633](https://github.com/woodpecker-ci/woodpecker/pull/5633)] - Add repo filter options to GetRepos api [[#5631](https://github.com/woodpecker-ci/woodpecker/pull/5631)] - Add branch filter to cli pipeline purge [[#5616](https://github.com/woodpecker-ci/woodpecker/pull/5616)] - Switch to GitHub REST API to load changed files [[#5618](https://github.com/woodpecker-ci/woodpecker/pull/5618)] - Enhance Bitbucket Datacenter build status reporting [[#5611](https://github.com/woodpecker-ci/woodpecker/pull/5611)] - List all repos in repository view if user is admin [[#5595](https://github.com/woodpecker-ci/woodpecker/pull/5595)] - Add disabled badge to agents [[#5593](https://github.com/woodpecker-ci/woodpecker/pull/5593)] - Improve error message when agent fails to connect [[#5587](https://github.com/woodpecker-ci/woodpecker/pull/5587)] - local backend: test shells if unknown [[#5570](https://github.com/woodpecker-ci/woodpecker/pull/5570)] ### 🐛 Bug Fixes - Fix missing background in pipeline deploy popup [[#5630](https://github.com/woodpecker-ci/woodpecker/pull/5630)] - Support matrix environ badges only with no key-values [[#5578](https://github.com/woodpecker-ci/woodpecker/pull/5578)] - local backend: fix steps having logs form other steps [[#5582](https://github.com/woodpecker-ci/woodpecker/pull/5582)] - local backend: fix windows cmd.exe command escaping [[#5569](https://github.com/woodpecker-ci/woodpecker/pull/5569)] - Bump buildx and limit max parallel builds [[#5579](https://github.com/woodpecker-ci/woodpecker/pull/5579)] - Don't split language if not required [[#5576](https://github.com/woodpecker-ci/woodpecker/pull/5576)] ### 📚 Documentation - chore(deps): update docs npm deps non-major [[#5649](https://github.com/woodpecker-ci/woodpecker/pull/5649)] - Document Forge interface precisely [[#5636](https://github.com/woodpecker-ci/woodpecker/pull/5636)] - chore(deps): update dependency @types/node to v22.18.10 [[#5624](https://github.com/woodpecker-ci/woodpecker/pull/5624)] - chore(deps): update docs npm deps non-major [[#5622](https://github.com/woodpecker-ci/woodpecker/pull/5622)] - chore(deps): lock file maintenance [[#5607](https://github.com/woodpecker-ci/woodpecker/pull/5607)] - chore(deps): update dependency @tsconfig/docusaurus to v2.0.4 [[#5605](https://github.com/woodpecker-ci/woodpecker/pull/5605)] - chore(deps): update docs npm deps non-major [[#5600](https://github.com/woodpecker-ci/woodpecker/pull/5600)] - Fix Kubernetes install docs to use OCI artifacts instead of deprecated helm chart [[#5596](https://github.com/woodpecker-ci/woodpecker/pull/5596)] - Document pipeline backend engine interface precisely [[#5583](https://github.com/woodpecker-ci/woodpecker/pull/5583)] ### 📦️ Dependency - chore(deps): update dependency simple-icons to v15.17.0 [[#5655](https://github.com/woodpecker-ci/woodpecker/pull/5655)] - chore(deps): update dependency jsdom to v27.0.1 [[#5653](https://github.com/woodpecker-ci/woodpecker/pull/5653)] - fix(deps): update module github.com/google/go-github/v75 to v76 [[#5652](https://github.com/woodpecker-ci/woodpecker/pull/5652)] - chore(deps): update dependency @antfu/eslint-config to v6 [[#5651](https://github.com/woodpecker-ci/woodpecker/pull/5651)] - chore(deps): update web npm deps non-major [[#5650](https://github.com/woodpecker-ci/woodpecker/pull/5650)] - chore(deps): update dependency golang to v1.25.3 [[#5648](https://github.com/woodpecker-ci/woodpecker/pull/5648)] - fix(deps): update module github.com/yaronf/httpsign to v0.3.3 [[#5647](https://github.com/woodpecker-ci/woodpecker/pull/5647)] - fix(deps): update module github.com/charmbracelet/huh to v0.8.0 [[#5643](https://github.com/woodpecker-ci/woodpecker/pull/5643)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.157.0 [[#5640](https://github.com/woodpecker-ci/woodpecker/pull/5640)] - chore(deps): lock file maintenance [[#5634](https://github.com/woodpecker-ci/woodpecker/pull/5634)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.156.0 [[#5626](https://github.com/woodpecker-ci/woodpecker/pull/5626)] - chore(deps): lock file maintenance [[#5627](https://github.com/woodpecker-ci/woodpecker/pull/5627)] - chore(deps): update dependency @types/node to v22.18.10 [[#5625](https://github.com/woodpecker-ci/woodpecker/pull/5625)] - chore(deps): update web npm deps non-major [[#5623](https://github.com/woodpecker-ci/woodpecker/pull/5623)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.3 [[#5621](https://github.com/woodpecker-ci/woodpecker/pull/5621)] - chore(deps): update dependency golang to v1.25.2 [[#5620](https://github.com/woodpecker-ci/woodpecker/pull/5620)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.155.0 [[#5617](https://github.com/woodpecker-ci/woodpecker/pull/5617)] - fix(deps): update golang-packages [[#5614](https://github.com/woodpecker-ci/woodpecker/pull/5614)] - fix(deps): update golang-packages [[#5610](https://github.com/woodpecker-ci/woodpecker/pull/5610)] - chore(deps): update dependency simple-icons to v15.16.1 [[#5606](https://github.com/woodpecker-ci/woodpecker/pull/5606)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.151.0 [[#5604](https://github.com/woodpecker-ci/woodpecker/pull/5604)] - chore(deps): update woodpeckerci/plugin-git docker tag to v2.7.0 [[#5603](https://github.com/woodpecker-ci/woodpecker/pull/5603)] - chore(deps): update web npm deps non-major [[#5602](https://github.com/woodpecker-ci/woodpecker/pull/5602)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.6 [[#5601](https://github.com/woodpecker-ci/woodpecker/pull/5601)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.4.1 [[#5598](https://github.com/woodpecker-ci/woodpecker/pull/5598)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.2 [[#5599](https://github.com/woodpecker-ci/woodpecker/pull/5599)] - fix(deps): update golang-packages [[#5594](https://github.com/woodpecker-ci/woodpecker/pull/5594)] - chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.2 [[#5577](https://github.com/woodpecker-ci/woodpecker/pull/5577)] - chore(deps): lock file maintenance [[#5566](https://github.com/woodpecker-ci/woodpecker/pull/5566)] ### Misc - flake.lock: Update [[#5635](https://github.com/woodpecker-ci/woodpecker/pull/5635)] - chore(deps): drop `github.com/gorilla/securecookie` [[#5609](https://github.com/woodpecker-ci/woodpecker/pull/5609)] - Announce only stable releases [[#5580](https://github.com/woodpecker-ci/woodpecker/pull/5580)] ## [3.10.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.10.0) - 2025-09-28 ### ❤️ Thanks to all contributors! ❤️ @6543, @Gusted, @da-Kai, @henkka, @hhamalai, @j04n-f, @klausi85, @marcusramberg, @qwerty287, @xoxys, @zhedazijingang ### 🔒 Security - chore(deps): update dependency vite to v7.1.5 [security] [[#5495](https://github.com/woodpecker-ci/woodpecker/pull/5495)] ### ✨ Features - New event pull request metadata [[#5214](https://github.com/woodpecker-ci/woodpecker/pull/5214)] - Add task UUID label to Kubernetes pods [[#5544](https://github.com/woodpecker-ci/woodpecker/pull/5544)] - feat: expose listing available organizations via woodpecker-go / CLI [[#5481](https://github.com/woodpecker-ci/woodpecker/pull/5481)] - Add milestone to metadata [[#5174](https://github.com/woodpecker-ci/woodpecker/pull/5174)] ### 📈 Enhancement - Trace errors during SetupWorkflow, make service step setup errors visible to user [[#5559](https://github.com/woodpecker-ci/woodpecker/pull/5559)] - Enable completion support for cli [[#5552](https://github.com/woodpecker-ci/woodpecker/pull/5552)] - Add `StepFinished` to log service [[#5530](https://github.com/woodpecker-ci/woodpecker/pull/5530)] - Migrate to mockery v3 [[#5547](https://github.com/woodpecker-ci/woodpecker/pull/5547)] - Show human readable information in queue info [[#5516](https://github.com/woodpecker-ci/woodpecker/pull/5516)] - feat(bitbucketdatacenter): Implement missing OrgMembership method [[#5476](https://github.com/woodpecker-ci/woodpecker/pull/5476)] - Cleanup columns in forges table [[#5517](https://github.com/woodpecker-ci/woodpecker/pull/5517)] - Allow to get secrets from file [[#5509](https://github.com/woodpecker-ci/woodpecker/pull/5509)] - refactor: use slices.Contains to simplify [[#5468](https://github.com/woodpecker-ci/woodpecker/pull/5468)] - Hide unsupported forge options [[#5465](https://github.com/woodpecker-ci/woodpecker/pull/5465)] - Collapse changed files in file-tree [[#5451](https://github.com/woodpecker-ci/woodpecker/pull/5451)] - Simplify queue interface [[#5449](https://github.com/woodpecker-ci/woodpecker/pull/5449)] ### 🐛 Bug Fixes - Support for pull requests opened events from forked repositories [[#5536](https://github.com/woodpecker-ci/woodpecker/pull/5536)] - Add back-off retry for pod log streaming to kubernetes backend [[#5550](https://github.com/woodpecker-ci/woodpecker/pull/5550)] - Fix dir not found handling [[#5533](https://github.com/woodpecker-ci/woodpecker/pull/5533)] - Show readable error [[#5501](https://github.com/woodpecker-ci/woodpecker/pull/5501)] - fix: allow spaces in cli string slices [[#5494](https://github.com/woodpecker-ci/woodpecker/pull/5494)] - fix: changed schema definition for "backend_options.kubernetes.tolerations" to accept an array of objects [[#5478](https://github.com/woodpecker-ci/woodpecker/pull/5478)] - Print execution errors [[#5448](https://github.com/woodpecker-ci/woodpecker/pull/5448)] ### 📚 Documentation - chore(deps): update dependency @types/react to v19.1.15 [[#5562](https://github.com/woodpecker-ci/woodpecker/pull/5562)] - chore(deps): update docs npm deps non-major [[#5554](https://github.com/woodpecker-ci/woodpecker/pull/5554)] - Add MCP tool to awesome docs [[#5546](https://github.com/woodpecker-ci/woodpecker/pull/5546)] - chore(deps): update docs npm deps non-major [[#5527](https://github.com/woodpecker-ci/woodpecker/pull/5527)] - chore(deps): update docs npm deps non-major [[#5512](https://github.com/woodpecker-ci/woodpecker/pull/5512)] - Add a blog post [[#5510](https://github.com/woodpecker-ci/woodpecker/pull/5510)] - chore(deps): update docs npm deps non-major [[#5503](https://github.com/woodpecker-ci/woodpecker/pull/5503)] - docs: add SonarQube to plugins list [[#5502](https://github.com/woodpecker-ci/woodpecker/pull/5502)] - Add Bitbucket key limit known issue [[#5497](https://github.com/woodpecker-ci/woodpecker/pull/5497)] - chore(deps): update dependency @types/node to v22.18.1 [[#5484](https://github.com/woodpecker-ci/woodpecker/pull/5484)] - chore(deps): update docs npm deps non-major [[#5472](https://github.com/woodpecker-ci/woodpecker/pull/5472)] - Add ui proxy docs [[#5459](https://github.com/woodpecker-ci/woodpecker/pull/5459)] - chore(deps): update dependency @types/react to v19.1.11 [[#5454](https://github.com/woodpecker-ci/woodpecker/pull/5454)] - Add easypanel community package [[#5446](https://github.com/woodpecker-ci/woodpecker/pull/5446)] - Add some blogs and videos [[#5445](https://github.com/woodpecker-ci/woodpecker/pull/5445)] ### 📦️ Dependency - chore(deps): update dependency vue-tsc to v3.1.0 [[#5563](https://github.com/woodpecker-ci/woodpecker/pull/5563)] - fix(deps): update golang-packages [[#5561](https://github.com/woodpecker-ci/woodpecker/pull/5561)] - chore(deps): update postgres docker tag to v18 [[#5557](https://github.com/woodpecker-ci/woodpecker/pull/5557)] - chore(deps): update docker.io/postgres docker tag to v18 [[#5556](https://github.com/woodpecker-ci/woodpecker/pull/5556)] - chore(deps): update web npm deps non-major [[#5553](https://github.com/woodpecker-ci/woodpecker/pull/5553)] - chore(deps): update pre-commit hook hadolint/hadolint to v2.14.0 [[#5555](https://github.com/woodpecker-ci/woodpecker/pull/5555)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.148.0 [[#5548](https://github.com/woodpecker-ci/woodpecker/pull/5548)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.147.1 [[#5541](https://github.com/woodpecker-ci/woodpecker/pull/5541)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.5.0 [[#5535](https://github.com/woodpecker-ci/woodpecker/pull/5535)] - fix(deps): update dependency simple-icons to v15.16.0 [[#5532](https://github.com/woodpecker-ci/woodpecker/pull/5532)] - fix(deps): update module github.com/gin-gonic/gin to v1.11.0 [[#5531](https://github.com/woodpecker-ci/woodpecker/pull/5531)] - fix(deps): update web npm deps non-major [[#5528](https://github.com/woodpecker-ci/woodpecker/pull/5528)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.146.0 [[#5524](https://github.com/woodpecker-ci/woodpecker/pull/5524)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.145.0 [[#5523](https://github.com/woodpecker-ci/woodpecker/pull/5523)] - chore(deps): lock file maintenance [[#5514](https://github.com/woodpecker-ci/woodpecker/pull/5514)] - fix(deps): update dependency marked to v16.3.0 [[#5513](https://github.com/woodpecker-ci/woodpecker/pull/5513)] - fix(deps): update dependency simple-icons to v15.15.0 [[#5508](https://github.com/woodpecker-ci/woodpecker/pull/5508)] - chore(deps): update dependency jsdom to v27 [[#5506](https://github.com/woodpecker-ci/woodpecker/pull/5506)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.144.1 [[#5505](https://github.com/woodpecker-ci/woodpecker/pull/5505)] - chore(deps): update web npm deps non-major [[#5504](https://github.com/woodpecker-ci/woodpecker/pull/5504)] - fix(deps): update golang-packages [[#5499](https://github.com/woodpecker-ci/woodpecker/pull/5499)] - fix(deps): update golang-packages [[#5496](https://github.com/woodpecker-ci/woodpecker/pull/5496)] - fix(deps): update golang-packages [[#5493](https://github.com/woodpecker-ci/woodpecker/pull/5493)] - chore(deps): lock file maintenance [[#5492](https://github.com/woodpecker-ci/woodpecker/pull/5492)] - fix(deps): update golang-packages [[#5491](https://github.com/woodpecker-ci/woodpecker/pull/5491)] - fix(deps): update dependency simple-icons to v15.14.0 [[#5490](https://github.com/woodpecker-ci/woodpecker/pull/5490)] - fix(deps): update module github.com/prometheus/client_golang to v1.23.2 [[#5489](https://github.com/woodpecker-ci/woodpecker/pull/5489)] - chore(deps): update dependency @intlify/unplugin-vue-i18n to v11 [[#5487](https://github.com/woodpecker-ci/woodpecker/pull/5487)] - fix(deps): update web npm deps non-major [[#5486](https://github.com/woodpecker-ci/woodpecker/pull/5486)] - chore(deps): update dependency golang to v1.25.1 [[#5485](https://github.com/woodpecker-ci/woodpecker/pull/5485)] - fix(deps): update module github.com/prometheus/client_golang to v1.23.1 [[#5483](https://github.com/woodpecker-ci/woodpecker/pull/5483)] - fix(deps): update golang-packages to v28.4.0+incompatible [[#5480](https://github.com/woodpecker-ci/woodpecker/pull/5480)] - fix(deps): update golang-packages [[#5479](https://github.com/woodpecker-ci/woodpecker/pull/5479)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.142.5 [[#5475](https://github.com/woodpecker-ci/woodpecker/pull/5475)] - fix(deps): update web npm deps non-major [[#5473](https://github.com/woodpecker-ci/woodpecker/pull/5473)] - fix(deps): update golang-packages [[#5467](https://github.com/woodpecker-ci/woodpecker/pull/5467)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.142.2 [[#5466](https://github.com/woodpecker-ci/woodpecker/pull/5466)] - fix(deps): update golang-packages [[#5463](https://github.com/woodpecker-ci/woodpecker/pull/5463)] - chore(deps): lock file maintenance [[#5458](https://github.com/woodpecker-ci/woodpecker/pull/5458)] - fix(deps): update golang-packages [[#5457](https://github.com/woodpecker-ci/woodpecker/pull/5457)] - fix(deps): update dependency simple-icons to v15.12.0 [[#5456](https://github.com/woodpecker-ci/woodpecker/pull/5456)] - fix(deps): update web npm deps non-major [[#5455](https://github.com/woodpecker-ci/woodpecker/pull/5455)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.142.0 [[#5452](https://github.com/woodpecker-ci/woodpecker/pull/5452)] - fix(deps): update golang-packages [[#5442](https://github.com/woodpecker-ci/woodpecker/pull/5442)] ### Misc - Fix prettier configs [[#5529](https://github.com/woodpecker-ci/woodpecker/pull/5529)] - eslint ignore html-indent in vue [[#5521](https://github.com/woodpecker-ci/woodpecker/pull/5521)] - Remove twitter from release template [[#5447](https://github.com/woodpecker-ci/woodpecker/pull/5447)] ## [3.9.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.9.0) - 2025-08-20 ### ❤️ Thanks to all contributors! ❤️ @6543, @anbraten, @clintonsteiner, @henkka, @hhamalai, @hrfee, @ivaltryek, @lilioid, @qwerty287, @scottshotgg, @wgroeneveld, @xoxys ### 🔒 Security - Remediate webpack vulnerability in webpack-dev-server [[#5264](https://github.com/woodpecker-ci/woodpecker/pull/5264)] - fix(deps): update module github.com/docker/docker to v28.3.3+incompatible [security] [[#5373](https://github.com/woodpecker-ci/woodpecker/pull/5373)] - Prevent secrets from leaking to Kubernetes API Server logs [[#5305](https://github.com/woodpecker-ci/woodpecker/pull/5305)] ### ✨ Features - feat(k8s): Kubernetes namespace per organization [[#5309](https://github.com/woodpecker-ci/woodpecker/pull/5309)] - Add and edit additional forges in UI [[#5328](https://github.com/woodpecker-ci/woodpecker/pull/5328)] ### 📈 Enhancement - Rename oauth variables [[#5435](https://github.com/woodpecker-ci/woodpecker/pull/5435)] - Add `fsGroupChangePolicy` option to Kubernetes backend [[#5416](https://github.com/woodpecker-ci/woodpecker/pull/5416)] - Rework background colors for light/dark theme [[#5411](https://github.com/woodpecker-ci/woodpecker/pull/5411)] - Allow to set default approval mode [[#5406](https://github.com/woodpecker-ci/woodpecker/pull/5406)] - Add Agent-level Tolerations setting [[#5266](https://github.com/woodpecker-ci/woodpecker/pull/5266)] - feat(k8s): k8s priority class name config [[#5391](https://github.com/woodpecker-ci/woodpecker/pull/5391)] - Count reopening an pull as opening an pull [[#5370](https://github.com/woodpecker-ci/woodpecker/pull/5370)] - Add pipeline log fullscreen [[#5377](https://github.com/woodpecker-ci/woodpecker/pull/5377)] - Show changed files as file-tree [[#5379](https://github.com/woodpecker-ci/woodpecker/pull/5379)] - Replace header bg with border [[#5380](https://github.com/woodpecker-ci/woodpecker/pull/5380)] - Prevent body jump when scrollbar appears [[#5381](https://github.com/woodpecker-ci/woodpecker/pull/5381)] - Show oauth host and favicon on login [[#5376](https://github.com/woodpecker-ci/woodpecker/pull/5376)] - Support secrets in `cli exec` [[#5374](https://github.com/woodpecker-ci/woodpecker/pull/5374)] - Simplify backend types [[#5299](https://github.com/woodpecker-ci/woodpecker/pull/5299)] ### 🐛 Bug Fixes - Handle empty url and oauth_host on login page [[#5434](https://github.com/woodpecker-ci/woodpecker/pull/5434)] - Fix background color of pipeline step list [[#5431](https://github.com/woodpecker-ci/woodpecker/pull/5431)] - Fix bitbucket status sending [[#5372](https://github.com/woodpecker-ci/woodpecker/pull/5372)] - Correct OpenApi LookupOrg router path [[#5351](https://github.com/woodpecker-ci/woodpecker/pull/5351)] - fix(agent): handle context cancellation [[#5323](https://github.com/woodpecker-ci/woodpecker/pull/5323)] - woodpecker-go/types: fix time-related struct field tags [[#5343](https://github.com/woodpecker-ci/woodpecker/pull/5343)] - Reload repo on hook [[#5324](https://github.com/woodpecker-ci/woodpecker/pull/5324)] - Fix loading icons and add missing loading indicators [[#5329](https://github.com/woodpecker-ci/woodpecker/pull/5329)] - Use correct parameter for forge selection on login [[#5325](https://github.com/woodpecker-ci/woodpecker/pull/5325)] ### 📚 Documentation - chore(deps): lock file maintenance [[#5430](https://github.com/woodpecker-ci/woodpecker/pull/5430)] - chore(deps): update docs npm deps non-major [[#5420](https://github.com/woodpecker-ci/woodpecker/pull/5420)] - Remove X link [[#5412](https://github.com/woodpecker-ci/woodpecker/pull/5412)] - fix(deps): update docs npm deps non-major [[#5395](https://github.com/woodpecker-ci/woodpecker/pull/5395)] - fix(deps): update docs npm deps non-major [[#5384](https://github.com/woodpecker-ci/woodpecker/pull/5384)] - Remove references of kaniko [[#5371](https://github.com/woodpecker-ci/woodpecker/pull/5371)] - Add ASCII JUnit Test Report plugin [[#5355](https://github.com/woodpecker-ci/woodpecker/pull/5355)] - fix(deps): update docs npm deps non-major [[#5340](https://github.com/woodpecker-ci/woodpecker/pull/5340)] - chore(deps): update docs npm deps non-major [[#5316](https://github.com/woodpecker-ci/woodpecker/pull/5316)] ### 📦️ Dependency - fix(deps): update module google.golang.org/grpc to v1.75.0 [[#5437](https://github.com/woodpecker-ci/woodpecker/pull/5437)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.141.1 [[#5432](https://github.com/woodpecker-ci/woodpecker/pull/5432)] - chore(deps): update golang-lang [[#5423](https://github.com/woodpecker-ci/woodpecker/pull/5423)] - chore(deps): update docker.io/golang docker tag to v1.25 [[#5422](https://github.com/woodpecker-ci/woodpecker/pull/5422)] - fix(deps): update dependency simple-icons to v15.11.0 [[#5427](https://github.com/woodpecker-ci/woodpecker/pull/5427)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.4.0 [[#5425](https://github.com/woodpecker-ci/woodpecker/pull/5425)] - chore(deps): update postgres docker tag to v17.6 [[#5424](https://github.com/woodpecker-ci/woodpecker/pull/5424)] - fix(deps): update web npm deps non-major [[#5421](https://github.com/woodpecker-ci/woodpecker/pull/5421)] - fix(deps): update golang-packages [[#5415](https://github.com/woodpecker-ci/woodpecker/pull/5415)] - fix(deps): update golang-packages [[#5413](https://github.com/woodpecker-ci/woodpecker/pull/5413)] - fix(deps): update golang-packages [[#5407](https://github.com/woodpecker-ci/woodpecker/pull/5407)] - chore(deps): lock file maintenance [[#5404](https://github.com/woodpecker-ci/woodpecker/pull/5404)] - chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v6 [[#5399](https://github.com/woodpecker-ci/woodpecker/pull/5399)] - fix(deps): update dependency simple-icons to v15.10.0 [[#5400](https://github.com/woodpecker-ci/woodpecker/pull/5400)] - fix(deps): update web npm deps non-major [[#5396](https://github.com/woodpecker-ci/woodpecker/pull/5396)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.4.0 [[#5394](https://github.com/woodpecker-ci/woodpecker/pull/5394)] - chore(deps): update dependency golang to v1.24.6 [[#5393](https://github.com/woodpecker-ci/woodpecker/pull/5393)] - fix(deps): update golang-packages [[#5392](https://github.com/woodpecker-ci/woodpecker/pull/5392)] - chore(deps): lock file maintenance [[#5388](https://github.com/woodpecker-ci/woodpecker/pull/5388)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.3.1 [[#5386](https://github.com/woodpecker-ci/woodpecker/pull/5386)] - fix(deps): update web npm deps non-major [[#5385](https://github.com/woodpecker-ci/woodpecker/pull/5385)] - fix(deps): update module github.com/prometheus/client_golang to v1.23.0 [[#5382](https://github.com/woodpecker-ci/woodpecker/pull/5382)] - fix(deps): update golang-packages [[#5375](https://github.com/woodpecker-ci/woodpecker/pull/5375)] - chore(deps): lock file maintenance [[#5369](https://github.com/woodpecker-ci/woodpecker/pull/5369)] - fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.9.1 [[#5365](https://github.com/woodpecker-ci/woodpecker/pull/5365)] - fix(deps): update module github.com/google/go-github/v73 to v74 [[#5363](https://github.com/woodpecker-ci/woodpecker/pull/5363)] - chore(deps): update dependency @antfu/eslint-config to v5 [[#5362](https://github.com/woodpecker-ci/woodpecker/pull/5362)] - chore(deps): update web npm deps non-major [[#5361](https://github.com/woodpecker-ci/woodpecker/pull/5361)] - chore(deps): update docker.io/mysql docker tag to v9.4.0 [[#5359](https://github.com/woodpecker-ci/woodpecker/pull/5359)] - fix(deps): update golang-packages [[#5356](https://github.com/woodpecker-ci/woodpecker/pull/5356)] - 📦 update web dependencies [[#5352](https://github.com/woodpecker-ci/woodpecker/pull/5352)] - chore(config): migrate renovate config [[#5350](https://github.com/woodpecker-ci/woodpecker/pull/5350)] - chore(deps): lock file maintenance [[#5348](https://github.com/woodpecker-ci/woodpecker/pull/5348)] - fix(deps): update golang-packages [[#5347](https://github.com/woodpecker-ci/woodpecker/pull/5347)] - fix(deps): update golang-packages [[#5336](https://github.com/woodpecker-ci/woodpecker/pull/5336)] - chore(deps): lock file maintenance [[#5344](https://github.com/woodpecker-ci/woodpecker/pull/5344)] - fix(deps): update web npm deps non-major [[#5341](https://github.com/woodpecker-ci/woodpecker/pull/5341)] - fix(deps): update dependency vue-i18n to v11.1.10 [security] [[#5335](https://github.com/woodpecker-ci/woodpecker/pull/5335)] - fix(deps): update golang-packages [[#5333](https://github.com/woodpecker-ci/woodpecker/pull/5333)] - chore(deps): lock file maintenance [[#5320](https://github.com/woodpecker-ci/woodpecker/pull/5320)] - fix(deps): update web npm deps non-major [[#5317](https://github.com/woodpecker-ci/woodpecker/pull/5317)] - fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.9.0 [[#5318](https://github.com/woodpecker-ci/woodpecker/pull/5318)] - chore(deps): update dependency golang to v1.24.5 [[#5314](https://github.com/woodpecker-ci/woodpecker/pull/5314)] - fix(deps): update golang-packages [[#5313](https://github.com/woodpecker-ci/woodpecker/pull/5313)] - fix(deps): update golang-packages [[#5311](https://github.com/woodpecker-ci/woodpecker/pull/5311)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.134.0 [[#5308](https://github.com/woodpecker-ci/woodpecker/pull/5308)] - chore(deps): lock file maintenance [[#5307](https://github.com/woodpecker-ci/woodpecker/pull/5307)] ### Misc - 🧑‍💻 Add support for proxying to existing woodpecker server [[#5354](https://github.com/woodpecker-ci/woodpecker/pull/5354)] - Update and improve nix flake [[#5349](https://github.com/woodpecker-ci/woodpecker/pull/5349)] - Update issue number for link checker [[#5327](https://github.com/woodpecker-ci/woodpecker/pull/5327)] ## [3.8.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.8.0) - 2025-07-05 ### ❤️ Thanks to all contributors! ❤️ @OCram85, @henkka, @johanvdw, @mmatous, @qwerty287 ### 📚 Documentation - chore(deps): lock file maintenance [[#5302](https://github.com/woodpecker-ci/woodpecker/pull/5302)] - chore(deps): update dependency @types/node to v22.15.34 [[#5280](https://github.com/woodpecker-ci/woodpecker/pull/5280)] - chore(deps): update dependency @types/node to v22.15.33 [[#5277](https://github.com/woodpecker-ci/woodpecker/pull/5277)] - fix(deps): update docs npm deps non-major [[#5267](https://github.com/woodpecker-ci/woodpecker/pull/5267)] - add Peckify plugin [[#5260](https://github.com/woodpecker-ci/woodpecker/pull/5260)] - fix(deps): update docs npm deps non-major [[#5252](https://github.com/woodpecker-ci/woodpecker/pull/5252)] - fix(deps): update docs npm deps non-major [[#5226](https://github.com/woodpecker-ci/woodpecker/pull/5226)] ### 🐛 Bug Fixes - Fix gitlab MR fetching [[#5287](https://github.com/woodpecker-ci/woodpecker/pull/5287)] - Use pipeline number in title [[#5275](https://github.com/woodpecker-ci/woodpecker/pull/5275)] - Adjust documentation urls [[#5273](https://github.com/woodpecker-ci/woodpecker/pull/5273)] - Fix doc links in agent settings [[#5251](https://github.com/woodpecker-ci/woodpecker/pull/5251)] ### 📈 Enhancement - Add pipeline author and avatar env vars [[#5227](https://github.com/woodpecker-ci/woodpecker/pull/5227)] - Support for pull request file changes in bitbucketdatacenter [[#5205](https://github.com/woodpecker-ci/woodpecker/pull/5205)] ### 📦️ Dependency - chore(deps): update dependency vue-tsc to v3 [[#5301](https://github.com/woodpecker-ci/woodpecker/pull/5301)] - chore(deps): update web npm deps non-major [[#5300](https://github.com/woodpecker-ci/woodpecker/pull/5300)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.3.0 [[#5298](https://github.com/woodpecker-ci/woodpecker/pull/5298)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.1 [[#5297](https://github.com/woodpecker-ci/woodpecker/pull/5297)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.2 [[#5295](https://github.com/woodpecker-ci/woodpecker/pull/5295)] - chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.1 [[#5296](https://github.com/woodpecker-ci/woodpecker/pull/5296)] - chore(deps): lock file maintenance [[#5289](https://github.com/woodpecker-ci/woodpecker/pull/5289)] - fix(deps): update web npm deps non-major [[#5281](https://github.com/woodpecker-ci/woodpecker/pull/5281)] - fix(deps): update golang-packages [[#5291](https://github.com/woodpecker-ci/woodpecker/pull/5291)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.1 [[#5288](https://github.com/woodpecker-ci/woodpecker/pull/5288)] - fix(deps): update dependency marked to v16 [[#5284](https://github.com/woodpecker-ci/woodpecker/pull/5284)] - chore(deps): update dependency @vitejs/plugin-vue to v6 [[#5282](https://github.com/woodpecker-ci/woodpecker/pull/5282)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.0 [[#5286](https://github.com/woodpecker-ci/woodpecker/pull/5286)] - chore(deps): update dependency vite to v7 [[#5283](https://github.com/woodpecker-ci/woodpecker/pull/5283)] - fix(deps): update module github.com/google/go-github/v72 to v73 [[#5285](https://github.com/woodpecker-ci/woodpecker/pull/5285)] - chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.6.2 [[#5278](https://github.com/woodpecker-ci/woodpecker/pull/5278)] - fix(deps): update golang-packages to v28.3.0+incompatible [[#5274](https://github.com/woodpecker-ci/woodpecker/pull/5274)] - chore(deps): lock file maintenance [[#5271](https://github.com/woodpecker-ci/woodpecker/pull/5271)] - fix(deps): update dependency vue-i18n to v11.1.7 [[#5270](https://github.com/woodpecker-ci/woodpecker/pull/5270)] - fix(deps): update dependency simple-icons to v15.3.0 [[#5269](https://github.com/woodpecker-ci/woodpecker/pull/5269)] - fix(deps): update web npm deps non-major [[#5268](https://github.com/woodpecker-ci/woodpecker/pull/5268)] - fix(deps): update golang-packages to v0.33.2 [[#5265](https://github.com/woodpecker-ci/woodpecker/pull/5265)] - fix(deps): update golang-packages [[#5261](https://github.com/woodpecker-ci/woodpecker/pull/5261)] - fix(deps): update module github.com/go-viper/mapstructure/v2 to v2.3.0 [[#5259](https://github.com/woodpecker-ci/woodpecker/pull/5259)] - chore(deps): lock file maintenance [[#5257](https://github.com/woodpecker-ci/woodpecker/pull/5257)] - fix(deps): update dependency simple-icons to v15.2.0 [[#5256](https://github.com/woodpecker-ci/woodpecker/pull/5256)] - fix(deps): update web npm deps non-major [[#5254](https://github.com/woodpecker-ci/woodpecker/pull/5254)] - chore(deps): update gitea/gitea docker tag to v1.24 [[#5253](https://github.com/woodpecker-ci/woodpecker/pull/5253)] - fix(deps): update golang-packages [[#5250](https://github.com/woodpecker-ci/woodpecker/pull/5250)] - chore(deps): lock file maintenance [[#5233](https://github.com/woodpecker-ci/woodpecker/pull/5233)] - fix(deps): update dependency simple-icons to v15.1.0 [[#5246](https://github.com/woodpecker-ci/woodpecker/pull/5246)] - fix(deps): update web npm deps non-major [[#5244](https://github.com/woodpecker-ci/woodpecker/pull/5244)] - fix(deps): update golang-packages [[#5242](https://github.com/woodpecker-ci/woodpecker/pull/5242)] - chore(deps): update dependency golang to v1.24.4 [[#5241](https://github.com/woodpecker-ci/woodpecker/pull/5241)] ### Misc - Disable package name linting [[#5294](https://github.com/woodpecker-ci/woodpecker/pull/5294)] ## [3.7.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.7.0) - 2025-06-06 ### ❤️ Thanks to all contributors! ❤️ @6543, @Epsilon02, @Levy-Tal, @OCram85, @Spiffyk, @SuperSandro2000, @deltamualpha, @qwerty287, @rruzicic, @sebastinez, @xoxys ### 📚 Documentation - update docs-link for todo checker [[#5236](https://github.com/woodpecker-ci/woodpecker/pull/5236)] - Add `sccache` plugin [[#5234](https://github.com/woodpecker-ci/woodpecker/pull/5234)] - fix(deps): update dependency redocusaurus to v2.3.0 [[#5203](https://github.com/woodpecker-ci/woodpecker/pull/5203)] - chore(deps): update docs npm deps non-major [[#5197](https://github.com/woodpecker-ci/woodpecker/pull/5197)] - Add reference to woodpecker-community plugin org [[#5186](https://github.com/woodpecker-ci/woodpecker/pull/5186)] - fix(deps): update docs npm deps non-major [[#5183](https://github.com/woodpecker-ci/woodpecker/pull/5183)] - Move `gitea-package` plugin to codeberg [[#5175](https://github.com/woodpecker-ci/woodpecker/pull/5175)] - add Portainer Service Update plugin [[#5172](https://github.com/woodpecker-ci/woodpecker/pull/5172)] - Split 'pull' option docs from 'image' docs [[#5161](https://github.com/woodpecker-ci/woodpecker/pull/5161)] - chore(deps): update docs npm deps non-major [[#5164](https://github.com/woodpecker-ci/woodpecker/pull/5164)] ### 📈 Enhancement - Move forge webhook fixtures into own files [[#5216](https://github.com/woodpecker-ci/woodpecker/pull/5216)] - Treat no available route in grpc as fatal error [[#5192](https://github.com/woodpecker-ci/woodpecker/pull/5192)] ### 🐛 Bug Fixes - Always collect metrics (reverts #4667) [[#5213](https://github.com/woodpecker-ci/woodpecker/pull/5213)] - fix(bitbucketDC): manual event has broken commit link [[#5160](https://github.com/woodpecker-ci/woodpecker/pull/5160)] - fix(bitbucketdc): build status gets incorrectly reported on multi workflow builds [[#5178](https://github.com/woodpecker-ci/woodpecker/pull/5178)] - fix(bitbucketdc): build status not reported on PR builds [[#5162](https://github.com/woodpecker-ci/woodpecker/pull/5162)] ### 📦️ Dependency - fix(deps): update golang-packages to v28.2.1+incompatible [[#5217](https://github.com/woodpecker-ci/woodpecker/pull/5217)] - fix(deps): update dependency simple-icons to v15 [[#5232](https://github.com/woodpecker-ci/woodpecker/pull/5232)] - chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.5 [[#5230](https://github.com/woodpecker-ci/woodpecker/pull/5230)] - fix(deps): update web npm deps non-major [[#5228](https://github.com/woodpecker-ci/woodpecker/pull/5228)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.4.0 [[#5225](https://github.com/woodpecker-ci/woodpecker/pull/5225)] - chore(deps): update docker.io/alpine docker tag to v3.22 [[#5224](https://github.com/woodpecker-ci/woodpecker/pull/5224)] - fix(deps): update golang-packages [[#5209](https://github.com/woodpecker-ci/woodpecker/pull/5209)] - chore(deps): lock file maintenance [[#5204](https://github.com/woodpecker-ci/woodpecker/pull/5204)] - fix(deps): update dependency simple-icons to v14.15.0 [[#5202](https://github.com/woodpecker-ci/woodpecker/pull/5202)] - fix(deps): update dependency vue-i18n to v11.1.4 [[#5201](https://github.com/woodpecker-ci/woodpecker/pull/5201)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.6 [[#5200](https://github.com/woodpecker-ci/woodpecker/pull/5200)] - fix(deps): update web npm deps non-major [[#5198](https://github.com/woodpecker-ci/woodpecker/pull/5198)] - fix(deps): update module github.com/oklog/ulid/v2 to v2.1.1 [[#5194](https://github.com/woodpecker-ci/woodpecker/pull/5194)] - fix(deps): update module github.com/gin-gonic/gin to v1.10.1 [[#5193](https://github.com/woodpecker-ci/woodpecker/pull/5193)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.129.0 [[#5190](https://github.com/woodpecker-ci/woodpecker/pull/5190)] - chore(deps): lock file maintenance [[#5189](https://github.com/woodpecker-ci/woodpecker/pull/5189)] - chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.45.0 [[#5187](https://github.com/woodpecker-ci/woodpecker/pull/5187)] - fix(deps): update dependency simple-icons to v14.14.0 [[#5188](https://github.com/woodpecker-ci/woodpecker/pull/5188)] - fix(deps): update web npm deps non-major [[#5185](https://github.com/woodpecker-ci/woodpecker/pull/5185)] - fix(deps): update golang-packages to v0.33.1 [[#5184](https://github.com/woodpecker-ci/woodpecker/pull/5184)] - fix(deps): update golang-packages [[#5180](https://github.com/woodpecker-ci/woodpecker/pull/5180)] - chore(deps): lock file maintenance [[#5171](https://github.com/woodpecker-ci/woodpecker/pull/5171)] - fix(deps): update module github.com/google/go-github/v71 to v72 [[#5167](https://github.com/woodpecker-ci/woodpecker/pull/5167)] - fix(deps): update dependency simple-icons to v14.13.0 [[#5170](https://github.com/woodpecker-ci/woodpecker/pull/5170)] - fix(deps): update module github.com/urfave/cli/v3 to v3.3.3 [[#5169](https://github.com/woodpecker-ci/woodpecker/pull/5169)] - fix(deps): update web npm deps non-major [[#5166](https://github.com/woodpecker-ci/woodpecker/pull/5166)] - chore(deps): update postgres docker tag to v17.5 [[#5165](https://github.com/woodpecker-ci/woodpecker/pull/5165)] - chore(deps): update dependency golang to v1.24.3 [[#5163](https://github.com/woodpecker-ci/woodpecker/pull/5163)] ### Misc - Ignore direnv config and folder [[#5235](https://github.com/woodpecker-ci/woodpecker/pull/5235)] - flake.lock: Update [[#5206](https://github.com/woodpecker-ci/woodpecker/pull/5206)] - Add Bluesky post plugin [[#5156](https://github.com/woodpecker-ci/woodpecker/pull/5156)] ## [3.6.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.6.0) - 2025-05-06 ### ❤️ Thanks to all contributors! ❤️ @Spiffyk, @SuperSandro2000, @gsaslis, @joshuachp, @lukashass, @maurerle, @pat-s, @qwerty287, @renich, @sp1thas, @xoxys ### ✨ Features - Use docker go client directly [[#5134](https://github.com/woodpecker-ci/woodpecker/pull/5134)] ### 📚 Documentation - Simplify NixOS docs [[#5120](https://github.com/woodpecker-ci/woodpecker/pull/5120)] - chore(deps): lock file maintenance [[#5150](https://github.com/woodpecker-ci/woodpecker/pull/5150)] - plugins: Add SSH/SCP plugin [[#4871](https://github.com/woodpecker-ci/woodpecker/pull/4871)] - chore(deps): update dependency @types/node to v22.15.3 [[#5142](https://github.com/woodpecker-ci/woodpecker/pull/5142)] - chore(deps): lock file maintenance [[#5136](https://github.com/woodpecker-ci/woodpecker/pull/5136)] - Explain tasks [[#5129](https://github.com/woodpecker-ci/woodpecker/pull/5129)] - Mention named volumes [[#5130](https://github.com/woodpecker-ci/woodpecker/pull/5130)] - chore(deps): update docs npm deps non-major [[#5128](https://github.com/woodpecker-ci/woodpecker/pull/5128)] - Fix link to agent configuration in `v3.5` docs [[#5122](https://github.com/woodpecker-ci/woodpecker/pull/5122)] - Fix link to agent configuration in `next` docs [[#5119](https://github.com/woodpecker-ci/woodpecker/pull/5119)] - Move `plugin-s3` to Codeberg [[#5118](https://github.com/woodpecker-ci/woodpecker/pull/5118)] - Use slugified plugin urls in docs [[#5116](https://github.com/woodpecker-ci/woodpecker/pull/5116)] - Fix example value for `WOODPECKER_GRPC_ADDR` in autoscaler docs [[#5102](https://github.com/woodpecker-ci/woodpecker/pull/5102)] - .deb and .rpm installation commands fixed [[#5087](https://github.com/woodpecker-ci/woodpecker/pull/5087)] - chore(deps): update dependency @types/react to v19.1.2 [[#5107](https://github.com/woodpecker-ci/woodpecker/pull/5107)] - Slugify plugin names used for urls [[#5098](https://github.com/woodpecker-ci/woodpecker/pull/5098)] - Mention `backend_options` in workflow syntax docs [[#5096](https://github.com/woodpecker-ci/woodpecker/pull/5096)] - Document rootless container requirements for skip-clone [[#5056](https://github.com/woodpecker-ci/woodpecker/pull/5056)] ### 📈 Enhancement - View full pipeline duration in tooltip [[#5123](https://github.com/woodpecker-ci/woodpecker/pull/5123)] - Set dynamic page titles [[#5104](https://github.com/woodpecker-ci/woodpecker/pull/5104)] - Use centrally typed inject provide in Vue [[#5113](https://github.com/woodpecker-ci/woodpecker/pull/5113)] - Scroll to selected pipeline step [[#5103](https://github.com/woodpecker-ci/woodpecker/pull/5103)] ### 🐛 Bug Fixes - Fix args docs for admin secrets [[#5127](https://github.com/woodpecker-ci/woodpecker/pull/5127)] - Add name flag to admin secret add [[#5101](https://github.com/woodpecker-ci/woodpecker/pull/5101)] ### 📦️ Dependency - fix(deps): update golang-packages [[#5152](https://github.com/woodpecker-ci/woodpecker/pull/5152)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.6 [[#5149](https://github.com/woodpecker-ci/woodpecker/pull/5149)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.1 [[#5147](https://github.com/woodpecker-ci/woodpecker/pull/5147)] - chore(deps): update pre-commit hook adrienverge/yamllint to v1.37.1 [[#5148](https://github.com/woodpecker-ci/woodpecker/pull/5148)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6 [[#5144](https://github.com/woodpecker-ci/woodpecker/pull/5144)] - fix(deps): update web npm deps non-major [[#5143](https://github.com/woodpecker-ci/woodpecker/pull/5143)] - fix(deps): update module github.com/getkin/kin-openapi to v0.132.0 [[#5141](https://github.com/woodpecker-ci/woodpecker/pull/5141)] - chore(deps): update dependency vite to v6.3.4 [security] [[#5139](https://github.com/woodpecker-ci/woodpecker/pull/5139)] - fix(deps): update module github.com/urfave/cli/v3 to v3.3.2 [[#5137](https://github.com/woodpecker-ci/woodpecker/pull/5137)] - fix(deps): update module github.com/urfave/cli/v3 to v3.3.1 [[#5135](https://github.com/woodpecker-ci/woodpecker/pull/5135)] - fix(deps): update module github.com/docker/docker to v28 [[#5132](https://github.com/woodpecker-ci/woodpecker/pull/5132)] - fix(deps): update module github.com/docker/cli to v28 [[#5131](https://github.com/woodpecker-ci/woodpecker/pull/5131)] - fix(deps): update dependency vue-router to v4.5.1 [[#5126](https://github.com/woodpecker-ci/woodpecker/pull/5126)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.5 [[#5125](https://github.com/woodpecker-ci/woodpecker/pull/5125)] - fix(deps): update web npm deps non-major [[#5077](https://github.com/woodpecker-ci/woodpecker/pull/5077)] - fix(deps): update golang-packages [[#5121](https://github.com/woodpecker-ci/woodpecker/pull/5121)] - fix(deps): update golang-packages [[#5111](https://github.com/woodpecker-ci/woodpecker/pull/5111)] - chore(deps): lock file maintenance [[#5112](https://github.com/woodpecker-ci/woodpecker/pull/5112)] - chore(deps): update docker.io/mysql docker tag to v9.3.0 [[#5109](https://github.com/woodpecker-ci/woodpecker/pull/5109)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.2.0 [[#5110](https://github.com/woodpecker-ci/woodpecker/pull/5110)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.2 [[#5108](https://github.com/woodpecker-ci/woodpecker/pull/5108)] - fix(deps): update golang-packages [[#5097](https://github.com/woodpecker-ci/woodpecker/pull/5097)] ### Misc - Add pre-commit plugin [[#5146](https://github.com/woodpecker-ci/woodpecker/pull/5146)] - Fix gitpod golang version [[#5093](https://github.com/woodpecker-ci/woodpecker/pull/5093)] ## [3.5.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.5.2) - 2025-04-15 ### ❤️ Thanks to all contributors! ❤️ @xoxys ### 📚 Documentation - chore(deps): lock file maintenance [[#5092](https://github.com/woodpecker-ci/woodpecker/pull/5092)] - fix(deps): update docs npm deps non-major [[#5089](https://github.com/woodpecker-ci/woodpecker/pull/5089)] - Move plugin-surge docs to codeberg [[#5086](https://github.com/woodpecker-ci/woodpecker/pull/5086)] - chore(deps): lock file maintenance [[#5080](https://github.com/woodpecker-ci/woodpecker/pull/5080)] - chore(deps): update docs npm deps non-major [[#5075](https://github.com/woodpecker-ci/woodpecker/pull/5075)] ### 🐛 Bug Fixes - Avoid db errors while executing migrations check [[#5072](https://github.com/woodpecker-ci/woodpecker/pull/5072)] ### 📦️ Dependency - fix(deps): update module github.com/google/go-github/v70 to v71 [[#5090](https://github.com/woodpecker-ci/woodpecker/pull/5090)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.1 [[#5091](https://github.com/woodpecker-ci/woodpecker/pull/5091)] - chore(deps): update dependency vite to v6.2.6 [security] [[#5088](https://github.com/woodpecker-ci/woodpecker/pull/5088)] - fix(deps): update module github.com/prometheus/client_golang to v1.22.0 [[#5084](https://github.com/woodpecker-ci/woodpecker/pull/5084)] - fix(deps): update golang-packages [[#5083](https://github.com/woodpecker-ci/woodpecker/pull/5083)] - fix(deps): update module golang.org/x/crypto to v0.37.0 [[#5079](https://github.com/woodpecker-ci/woodpecker/pull/5079)] - fix(deps): update golang-packages [[#5078](https://github.com/woodpecker-ci/woodpecker/pull/5078)] - fix(deps): update module github.com/fsnotify/fsnotify to v1.9.0 [[#5076](https://github.com/woodpecker-ci/woodpecker/pull/5076)] - chore(deps): update dependency vite to v6.2.5 [security] [[#5074](https://github.com/woodpecker-ci/woodpecker/pull/5074)] ### Misc - Add markdown template for release umbrella issues [[#5055](https://github.com/woodpecker-ci/woodpecker/pull/5055)] ## [3.5.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.5.1) - 2025-04-04 ### ❤️ Thanks to all contributors! ❤️ @xoxys ### 🐛 Bug Fixes - Add missing icon for changes files tab [[#5068](https://github.com/woodpecker-ci/woodpecker/pull/5068)] - Improve CLI info text and remove markdown [[#5069](https://github.com/woodpecker-ci/woodpecker/pull/5069)] - Fix cli format flag fallback [[#5057](https://github.com/woodpecker-ci/woodpecker/pull/5057)] ### 📚 Documentation - chore(deps): update docs npm deps non-major [[#5060](https://github.com/woodpecker-ci/woodpecker/pull/5060)] ### 📦️ Dependency - fix(deps): update module code.gitea.io/sdk/gitea to v0.21.0 [[#5067](https://github.com/woodpecker-ci/woodpecker/pull/5067)] - chore(deps): lock file maintenance [[#5062](https://github.com/woodpecker-ci/woodpecker/pull/5062)] - fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.27 [[#5058](https://github.com/woodpecker-ci/woodpecker/pull/5058)] ## [3.5.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.5.0) - 2025-04-02 ### ❤️ Thanks to all contributors! ❤️ @6543, @Levy-Tal, @anbraten, @jenrik, @nekowinston, @qwerty287, @rhafer, @xoxys ### 🐛 Bug Fixes - BitbucketDC: add event pull request opened [[#5048](https://github.com/woodpecker-ci/woodpecker/pull/5048)] - Fix exclude path constraint behavior [[#5042](https://github.com/woodpecker-ci/woodpecker/pull/5042)] - Use pointer cursor for icon buttons [[#5002](https://github.com/woodpecker-ci/woodpecker/pull/5002)] - Add back cursor-pointer to pipeline step list buttons [[#4982](https://github.com/woodpecker-ci/woodpecker/pull/4982)] ### 📚 Documentation - chore(deps): lock file maintenance [[#5044](https://github.com/woodpecker-ci/woodpecker/pull/5044)] - chore(deps): lock file maintenance [[#5032](https://github.com/woodpecker-ci/woodpecker/pull/5032)] - Print at which file docs parsing failed [[#5040](https://github.com/woodpecker-ci/woodpecker/pull/5040)] - fix(deps): update dependency yaml to v2.7.1 [[#5029](https://github.com/woodpecker-ci/woodpecker/pull/5029)] - fix(deps): update docs npm deps non-major [[#5026](https://github.com/woodpecker-ci/woodpecker/pull/5026)] - Revert manual changes to changelog [[#5007](https://github.com/woodpecker-ci/woodpecker/pull/5007)] - Add missing docs for 3.x minor versions [[#4992](https://github.com/woodpecker-ci/woodpecker/pull/4992)] - chore(deps): lock file maintenance [[#5000](https://github.com/woodpecker-ci/woodpecker/pull/5000)] - fix(deps): update dependency redocusaurus to v2.2.2 [[#4998](https://github.com/woodpecker-ci/woodpecker/pull/4998)] - Add missing links to 3.x docs [[#4991](https://github.com/woodpecker-ci/woodpecker/pull/4991)] - chore(deps): update docs npm deps non-major [[#4987](https://github.com/woodpecker-ci/woodpecker/pull/4987)] - Rework secrets docs and document multiline secrets [[#4974](https://github.com/woodpecker-ci/woodpecker/pull/4974)] - Add documentation for WOODPECKER_EXPERT env vars [[#4972](https://github.com/woodpecker-ci/woodpecker/pull/4972)] ### 📈 Enhancement - add nushell support to local backend [[#5043](https://github.com/woodpecker-ci/woodpecker/pull/5043)] - Style navbar login button as navbar-link [[#5033](https://github.com/woodpecker-ci/woodpecker/pull/5033)] - Use xorm quoter for feed query [[#5018](https://github.com/woodpecker-ci/woodpecker/pull/5018)] - Use badge value instead of label for single values [[#5010](https://github.com/woodpecker-ci/woodpecker/pull/5010)] - Add icons to all tabs [[#4421](https://github.com/woodpecker-ci/woodpecker/pull/4421)] - Tag pipeline with source information [[#4796](https://github.com/woodpecker-ci/woodpecker/pull/4796)] - Add titles and descriptions to repos page [[#4981](https://github.com/woodpecker-ci/woodpecker/pull/4981)] ### 📦️ Dependency - fix(deps): update golang-packages [[#5046](https://github.com/woodpecker-ci/woodpecker/pull/5046)] - fix(deps): update module github.com/urfave/cli/v3 to v3.1.0 [[#5039](https://github.com/woodpecker-ci/woodpecker/pull/5039)] - chore(deps): update dependency vite to v6.2.4 [security] [[#5036](https://github.com/woodpecker-ci/woodpecker/pull/5036)] - fix(deps): update dependency simple-icons to v14.12.0 [[#5030](https://github.com/woodpecker-ci/woodpecker/pull/5030)] - chore(deps): update pre-commit hook golangci/golangci-lint to v2 [[#5028](https://github.com/woodpecker-ci/woodpecker/pull/5028)] - fix(deps): update web npm deps non-major [[#5027](https://github.com/woodpecker-ci/woodpecker/pull/5027)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.4 [[#5025](https://github.com/woodpecker-ci/woodpecker/pull/5025)] - fix(deps): update module golang.org/x/net to v0.38.0 [[#5024](https://github.com/woodpecker-ci/woodpecker/pull/5024)] - chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.3 [[#5021](https://github.com/woodpecker-ci/woodpecker/pull/5021)] - chore(deps): update dependency vite to v6.2.3 [security] [[#5014](https://github.com/woodpecker-ci/woodpecker/pull/5014)] - fix(deps): update golang-packages [[#5012](https://github.com/woodpecker-ci/woodpecker/pull/5012)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.2 [[#4997](https://github.com/woodpecker-ci/woodpecker/pull/4997)] - fix(deps): update dependency simple-icons to v14.11.1 [[#4999](https://github.com/woodpecker-ci/woodpecker/pull/4999)] - chore(deps): update pre-commit hook adrienverge/yamllint to v1.37.0 [[#4996](https://github.com/woodpecker-ci/woodpecker/pull/4996)] - fix(deps): update module github.com/rs/zerolog to v1.34.0 [[#4995](https://github.com/woodpecker-ci/woodpecker/pull/4995)] - chore(deps): update dependency @antfu/eslint-config to v4.11.0 [[#4994](https://github.com/woodpecker-ci/woodpecker/pull/4994)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.5 [[#4993](https://github.com/woodpecker-ci/woodpecker/pull/4993)] - fix(deps): update module github.com/google/go-github/v69 to v70 [[#4990](https://github.com/woodpecker-ci/woodpecker/pull/4990)] - fix(deps): update web npm deps non-major [[#4989](https://github.com/woodpecker-ci/woodpecker/pull/4989)] - chore(deps): update pre-commit non-major [[#4988](https://github.com/woodpecker-ci/woodpecker/pull/4988)] - fix(deps): update module github.com/golang-jwt/jwt/v5 to v5.2.2 [security] [[#4986](https://github.com/woodpecker-ci/woodpecker/pull/4986)] - fix(deps): update module github.com/go-sql-driver/mysql to v1.9.1 [[#4985](https://github.com/woodpecker-ci/woodpecker/pull/4985)] - fix(deps): update module github.com/getkin/kin-openapi to v0.131.0 [[#4984](https://github.com/woodpecker-ci/woodpecker/pull/4984)] - fix(deps): update module github.com/expr-lang/expr to v1.17.1 [[#4983](https://github.com/woodpecker-ci/woodpecker/pull/4983)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.126.0 [[#4976](https://github.com/woodpecker-ci/woodpecker/pull/4976)] ### Misc - Bump golangci-lint to v2 [[#5034](https://github.com/woodpecker-ci/woodpecker/pull/5034)] - Update flake development environment [[#5022](https://github.com/woodpecker-ci/woodpecker/pull/5022)] ## [3.4.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.4.0) - 2025-03-17 ### ❤️ Thanks to all contributors! ❤️ @qwerty287, @xoxys ### 📈 Enhancement - Remove woodpecker prefix from env var title in docs [[#4968](https://github.com/woodpecker-ci/woodpecker/pull/4968)] - Add backoff retry for store setup [[#4964](https://github.com/woodpecker-ci/woodpecker/pull/4964)] - Migrate repo output format to customizable output [[#4888](https://github.com/woodpecker-ci/woodpecker/pull/4888)] ### 📚 Documentation - chore(deps): lock file maintenance [[#4970](https://github.com/woodpecker-ci/woodpecker/pull/4970)] - fix(deps): update docs npm deps non-major [[#4958](https://github.com/woodpecker-ci/woodpecker/pull/4958)] - Add global var note [[#4956](https://github.com/woodpecker-ci/woodpecker/pull/4956)] - chore(deps): lock file maintenance [[#4948](https://github.com/woodpecker-ci/woodpecker/pull/4948)] - chore(deps): update dependency @types/node to v22.13.10 [[#4944](https://github.com/woodpecker-ci/woodpecker/pull/4944)] - chore(deps): update dependency axios to v1.8.2 [security] [[#4941](https://github.com/woodpecker-ci/woodpecker/pull/4941)] - Fix dockerhub links in docs [[#4931](https://github.com/woodpecker-ci/woodpecker/pull/4931)] ### 🐛 Bug Fixes - Fix fs owner in scratch-based container images [[#4961](https://github.com/woodpecker-ci/woodpecker/pull/4961)] ### 📦️ Dependency - fix(deps): update module github.com/expr-lang/expr to v1.17.0 [[#4969](https://github.com/woodpecker-ci/woodpecker/pull/4969)] - fix(deps): update dependency simple-icons to v14.11.0 [[#4966](https://github.com/woodpecker-ci/woodpecker/pull/4966)] - fix(deps): update golang-packages [[#4963](https://github.com/woodpecker-ci/woodpecker/pull/4963)] - chore(deps): update pre-commit hook adrienverge/yamllint to v1.36.1 [[#4962](https://github.com/woodpecker-ci/woodpecker/pull/4962)] - fix(deps): update dependency @vueuse/core to v13 [[#4960](https://github.com/woodpecker-ci/woodpecker/pull/4960)] - fix(deps): update web npm deps non-major [[#4959](https://github.com/woodpecker-ci/woodpecker/pull/4959)] - chore(deps): update pre-commit non-major [[#4957](https://github.com/woodpecker-ci/woodpecker/pull/4957)] - fix(deps): update golang-packages to v0.32.3 [[#4953](https://github.com/woodpecker-ci/woodpecker/pull/4953)] - fix(deps): update dependency prismjs to v1.30.0 [security] [[#4951](https://github.com/woodpecker-ci/woodpecker/pull/4951)] - chore(deps): update dependency @intlify/eslint-plugin-vue-i18n to v4 [[#4943](https://github.com/woodpecker-ci/woodpecker/pull/4943)] - fix(deps): update module al.essio.dev/pkg/shellescape to v1.6.0 [[#4947](https://github.com/woodpecker-ci/woodpecker/pull/4947)] - fix(deps): update dependency simple-icons to v14.10.0 [[#4946](https://github.com/woodpecker-ci/woodpecker/pull/4946)] - chore(deps): update dependency @types/node to v22.13.10 [[#4945](https://github.com/woodpecker-ci/woodpecker/pull/4945)] - fix(deps): update web npm deps non-major [[#4942](https://github.com/woodpecker-ci/woodpecker/pull/4942)] - fix(deps): update dependency vue-i18n to v11.1.2 [security] [[#4940](https://github.com/woodpecker-ci/woodpecker/pull/4940)] - fix(deps): update golang-packages [[#4936](https://github.com/woodpecker-ci/woodpecker/pull/4936)] - chore(deps): lock file maintenance [[#4933](https://github.com/woodpecker-ci/woodpecker/pull/4933)] - fix(deps): update golang-packages [[#4929](https://github.com/woodpecker-ci/woodpecker/pull/4929)] ## [3.3.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.3.0) - 2025-03-04 ### ❤️ Thanks to all contributors! ❤️ @Levy-Tal, @qwerty287, @xoxys ### 📚 Documentation - Refactor admin docs [[#4899](https://github.com/woodpecker-ci/woodpecker/pull/4899)] - chore(deps): lock file maintenance [[#4928](https://github.com/woodpecker-ci/woodpecker/pull/4928)] - chore(deps): update dependency @types/node to v22.13.9 [[#4925](https://github.com/woodpecker-ci/woodpecker/pull/4925)] - chore(deps): lock file maintenance [[#4922](https://github.com/woodpecker-ci/woodpecker/pull/4922)] - Add some blog posts [[#4921](https://github.com/woodpecker-ci/woodpecker/pull/4921)] - chore(deps): update dependency @types/node to v22.13.8 [[#4915](https://github.com/woodpecker-ci/woodpecker/pull/4915)] - Remove Slack plugin from examples [[#4914](https://github.com/woodpecker-ci/woodpecker/pull/4914)] - chore(deps): update docs npm deps non-major [[#4911](https://github.com/woodpecker-ci/woodpecker/pull/4911)] ### 🐛 Bug Fixes - Add migration to fix zero forge_id in orgs table [[#4924](https://github.com/woodpecker-ci/woodpecker/pull/4924)] - Fix unique constraint for orgs [[#4923](https://github.com/woodpecker-ci/woodpecker/pull/4923)] ### 📈 Enhancement - BitbucketDC: optimize repository search [[#4919](https://github.com/woodpecker-ci/woodpecker/pull/4919)] - Include forge type in netrc [[#4908](https://github.com/woodpecker-ci/woodpecker/pull/4908)] ### 📦️ Dependency - chore(deps): update dependency @types/node to v22.13.9 [[#4926](https://github.com/woodpecker-ci/woodpecker/pull/4926)] - chore(deps): update pre-commit non-major [[#4927](https://github.com/woodpecker-ci/woodpecker/pull/4927)] - chore(deps): update dependency @antfu/eslint-config to v4.4.0 [[#4917](https://github.com/woodpecker-ci/woodpecker/pull/4917)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.124.0 [[#4920](https://github.com/woodpecker-ci/woodpecker/pull/4920)] - chore(deps): update dependency @types/node to v22.13.8 [[#4916](https://github.com/woodpecker-ci/woodpecker/pull/4916)] - chore(deps): update dependency @types/lodash to v4.17.16 [[#4913](https://github.com/woodpecker-ci/woodpecker/pull/4913)] - chore(deps): update web npm deps non-major [[#4912](https://github.com/woodpecker-ci/woodpecker/pull/4912)] ## [3.2.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.2.0) - 2025-02-26 ### ❤️ Thanks to all contributors! ❤️ @DHandspikerWade, @anbraten, @arthurpro, @hhomar, @jenrik, @jpgleeson, @mark-pitblado, @maurerle, @qwerty287, @xoxys ### 🔒 Security - Fix approval requirement if PR is closed [[#4902](https://github.com/woodpecker-ci/woodpecker/pull/4902)] ### 📚 Documentation - chore(deps): lock file maintenance [[#4906](https://github.com/woodpecker-ci/woodpecker/pull/4906)] - chore(deps): update dependency axios to v1.8.1 [[#4905](https://github.com/woodpecker-ci/woodpecker/pull/4905)] - Fix typo on forgejo/gitea documentation [[#4898](https://github.com/woodpecker-ci/woodpecker/pull/4898)] - chore(deps): update docs npm deps non-major [[#4878](https://github.com/woodpecker-ci/woodpecker/pull/4878)] - plugins: add Hugo plugin for woodpecker [[#4870](https://github.com/woodpecker-ci/woodpecker/pull/4870)] - Add Microsoft Teams Notification (Advanced) plugin [[#4868](https://github.com/woodpecker-ci/woodpecker/pull/4868)] - chore(deps): update dependency @types/react to v19.0.9 [[#4864](https://github.com/woodpecker-ci/woodpecker/pull/4864)] - Drop versioned docs for v1 [[#4844](https://github.com/woodpecker-ci/woodpecker/pull/4844)] - Add a Home Assistant notification plugin [[#4841](https://github.com/woodpecker-ci/woodpecker/pull/4841)] ### 🐛 Bug Fixes - Use forge IDs for hook tokens [[#4897](https://github.com/woodpecker-ci/woodpecker/pull/4897)] - Fix nil dereference in Bitbucket webhook handling [[#4896](https://github.com/woodpecker-ci/woodpecker/pull/4896)] - Fix org assign on login [[#4817](https://github.com/woodpecker-ci/woodpecker/pull/4817)] - Directly fetch directory contents [[#4842](https://github.com/woodpecker-ci/woodpecker/pull/4842)] ### 📈 Enhancement - Remove eslint types [[#4893](https://github.com/woodpecker-ci/woodpecker/pull/4893)] - Add default option for allowing pull requests on repositories [[#4873](https://github.com/woodpecker-ci/woodpecker/pull/4873)] - Replace deprecated linter [[#4843](https://github.com/woodpecker-ci/woodpecker/pull/4843)] ### 📦️ Dependency - chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.2 [[#4903](https://github.com/woodpecker-ci/woodpecker/pull/4903)] - fix(deps): update web npm deps non-major [[#4904](https://github.com/woodpecker-ci/woodpecker/pull/4904)] - fix(deps): update golang-packages [[#4900](https://github.com/woodpecker-ci/woodpecker/pull/4900)] - chore(deps): lock file maintenance [[#4895](https://github.com/woodpecker-ci/woodpecker/pull/4895)] - chore(deps): update dependency vue-tsc to v2.2.4 [[#4894](https://github.com/woodpecker-ci/woodpecker/pull/4894)] - fix(deps): update dependency simple-icons to v14.8.0 [[#4891](https://github.com/woodpecker-ci/woodpecker/pull/4891)] - fix(deps): update golang-packages [[#4890](https://github.com/woodpecker-ci/woodpecker/pull/4890)] - chore(deps): update dependency @types/eslint__js to v9 [[#4884](https://github.com/woodpecker-ci/woodpecker/pull/4884)] - chore(deps): update pre-commit hook rbubley/mirrors-prettier to v3.5.2 [[#4883](https://github.com/woodpecker-ci/woodpecker/pull/4883)] - fix(deps): update module codeberg.org/mvdkleijn/forgejo-sdk/forgejo to v2 [[#4858](https://github.com/woodpecker-ci/woodpecker/pull/4858)] - fix(deps): update web npm deps non-major [[#4882](https://github.com/woodpecker-ci/woodpecker/pull/4882)] - chore(deps): update postgres docker tag to v17.4 [[#4881](https://github.com/woodpecker-ci/woodpecker/pull/4881)] - chore(deps): update woodpeckerci/plugin-git docker tag to v2.6.1 [[#4879](https://github.com/woodpecker-ci/woodpecker/pull/4879)] - chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.0 [[#4880](https://github.com/woodpecker-ci/woodpecker/pull/4880)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.5 [[#4877](https://github.com/woodpecker-ci/woodpecker/pull/4877)] - fix(deps): update module github.com/prometheus/client_golang to v1.21.0 [[#4874](https://github.com/woodpecker-ci/woodpecker/pull/4874)] - fix(deps): update module github.com/go-sql-driver/mysql to v1.9.0 [[#4872](https://github.com/woodpecker-ci/woodpecker/pull/4872)] - fix(deps): update module github.com/google/go-github/v69 to v69.2.0 [[#4869](https://github.com/woodpecker-ci/woodpecker/pull/4869)] - chore(deps): lock file maintenance [[#4866](https://github.com/woodpecker-ci/woodpecker/pull/4866)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.4.0 [[#4865](https://github.com/woodpecker-ci/woodpecker/pull/4865)] - fix(deps): update dependency simple-icons to v14.7.0 [[#4862](https://github.com/woodpecker-ci/woodpecker/pull/4862)] - fix(deps): update dependency pinia to v3 [[#4856](https://github.com/woodpecker-ci/woodpecker/pull/4856)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.123.0 [[#4860](https://github.com/woodpecker-ci/woodpecker/pull/4860)] - chore(deps): update dependency vue-tsc to v2.2.2 [[#4859](https://github.com/woodpecker-ci/woodpecker/pull/4859)] - fix(deps): update web npm deps non-major [[#4857](https://github.com/woodpecker-ci/woodpecker/pull/4857)] - chore(deps): update pre-commit non-major [[#4855](https://github.com/woodpecker-ci/woodpecker/pull/4855)] - chore(deps): update postgres docker tag to v17.3 [[#4854](https://github.com/woodpecker-ci/woodpecker/pull/4854)] - chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.24.x [[#4853](https://github.com/woodpecker-ci/woodpecker/pull/4853)] - chore(deps): update docker.io/golang docker tag to v1.24 [[#4852](https://github.com/woodpecker-ci/woodpecker/pull/4852)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.4 [[#4851](https://github.com/woodpecker-ci/woodpecker/pull/4851)] - fix(deps): update dependency @tailwindcss/vite to v4.0.6 [[#4846](https://github.com/woodpecker-ci/woodpecker/pull/4846)] - chore(deps): lock file maintenance [[#4845](https://github.com/woodpecker-ci/woodpecker/pull/4845)] - fix(deps): update dependency tailwindcss to v4 [[#4778](https://github.com/woodpecker-ci/woodpecker/pull/4778)] - fix(deps): update golang-packages [[#4839](https://github.com/woodpecker-ci/woodpecker/pull/4839)] ### Misc - kubernetes: create service for detached steps [[#4892](https://github.com/woodpecker-ci/woodpecker/pull/4892)] - docs: remove latest from docker compose example [[#4849](https://github.com/woodpecker-ci/woodpecker/pull/4849)] ## [3.1.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.1.0) - 2025-02-12 ### ❤️ Thanks to all contributors! ❤️ @Levy-Tal, @anbraten, @cduchenoy, @damuzhi0810, @lafriks, @mzampetakis, @pat-s, @qwerty287, @xoxys ### ✨ Features - Add allow list for approvals [[#4768](https://github.com/woodpecker-ci/woodpecker/pull/4768)] ### 🐛 Bug Fixes - Unsanitize user and org names in DB [[#4762](https://github.com/woodpecker-ci/woodpecker/pull/4762)] - Store/delete repos after forge communication [[#4827](https://github.com/woodpecker-ci/woodpecker/pull/4827)] - Fix k8s secret schema [[#4819](https://github.com/woodpecker-ci/woodpecker/pull/4819)] - Move section description to the top [[#4773](https://github.com/woodpecker-ci/woodpecker/pull/4773)] ### 📚 Documentation - Docs: Add Radicle forge addon [[#4833](https://github.com/woodpecker-ci/woodpecker/pull/4833)] - fix(deps): update docs npm deps non-major [[#4823](https://github.com/woodpecker-ci/woodpecker/pull/4823)] - chore(deps): update dependency isomorphic-dompurify to v2.21.0 [[#4805](https://github.com/woodpecker-ci/woodpecker/pull/4805)] - chore(deps): update dependency @types/node to v22.13.0 [[#4799](https://github.com/woodpecker-ci/woodpecker/pull/4799)] - Add bluesky post plugin [[#4549](https://github.com/woodpecker-ci/woodpecker/pull/4549)] - Various docs improvements [[#4772](https://github.com/woodpecker-ci/woodpecker/pull/4772)] - fix(deps): update docs npm deps non-major [[#4774](https://github.com/woodpecker-ci/woodpecker/pull/4774)] - Add git basic changelog [[#4755](https://github.com/woodpecker-ci/woodpecker/pull/4755)] ### 📈 Enhancement - Optimize repository list loading to return also latest pipeline info [[#4814](https://github.com/woodpecker-ci/woodpecker/pull/4814)] - Add Git Ref To Build Status in BitbucketDatacenter [[#4724](https://github.com/woodpecker-ci/woodpecker/pull/4724)] ### 📦️ Dependency - fix(deps): update golang-packages [[#4834](https://github.com/woodpecker-ci/woodpecker/pull/4834)] - fix(deps): update web npm deps non-major [[#4831](https://github.com/woodpecker-ci/woodpecker/pull/4831)] - fix(deps): update dependency simple-icons to v14.6.0 [[#4830](https://github.com/woodpecker-ci/woodpecker/pull/4830)] - fix(deps): update golang-packages [[#4829](https://github.com/woodpecker-ci/woodpecker/pull/4829)] - fix(deps): update web npm deps non-major to v4.0.5 [[#4828](https://github.com/woodpecker-ci/woodpecker/pull/4828)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.1 [[#4822](https://github.com/woodpecker-ci/woodpecker/pull/4822)] - fix(deps): update module github.com/google/go-github/v68 to v69 [[#4826](https://github.com/woodpecker-ci/woodpecker/pull/4826)] - fix(deps): update web npm deps non-major [[#4825](https://github.com/woodpecker-ci/woodpecker/pull/4825)] - fix(deps): update golang-packages [[#4812](https://github.com/woodpecker-ci/woodpecker/pull/4812)] - chore(deps): update dependency vitest to v3.0.5 [security] [[#4810](https://github.com/woodpecker-ci/woodpecker/pull/4810)] - chore(deps): lock file maintenance [[#4808](https://github.com/woodpecker-ci/woodpecker/pull/4808)] - chore(deps): update dependency @antfu/eslint-config to v4.1.1 [[#4806](https://github.com/woodpecker-ci/woodpecker/pull/4806)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.121.0 [[#4804](https://github.com/woodpecker-ci/woodpecker/pull/4804)] - fix(deps): update dependency simple-icons to v14.5.0 [[#4803](https://github.com/woodpecker-ci/woodpecker/pull/4803)] - fix(deps): update web npm deps non-major to v4.0.3 [[#4802](https://github.com/woodpecker-ci/woodpecker/pull/4802)] - fix(deps): update web npm deps non-major [[#4798](https://github.com/woodpecker-ci/woodpecker/pull/4798)] - fix(deps): update module github.com/getkin/kin-openapi to v0.129.0 [[#4790](https://github.com/woodpecker-ci/woodpecker/pull/4790)] - chore(deps): lock file maintenance [[#4783](https://github.com/woodpecker-ci/woodpecker/pull/4783)] - chore(deps): update dependency @antfu/eslint-config to v4.1.0 [[#4780](https://github.com/woodpecker-ci/woodpecker/pull/4780)] - fix(deps): update module github.com/bmatcuk/doublestar/v4 to v4.8.1 [[#4781](https://github.com/woodpecker-ci/woodpecker/pull/4781)] - chore(deps): update dependency @antfu/eslint-config to v4 [[#4779](https://github.com/woodpecker-ci/woodpecker/pull/4779)] - fix(deps): update web npm deps non-major [[#4777](https://github.com/woodpecker-ci/woodpecker/pull/4777)] - chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.44.0 [[#4776](https://github.com/woodpecker-ci/woodpecker/pull/4776)] - fix(deps): update module google.golang.org/protobuf to v1.36.4 [[#4775](https://github.com/woodpecker-ci/woodpecker/pull/4775)] - fix(deps): update module google.golang.org/grpc to v1.70.0 [[#4770](https://github.com/woodpecker-ci/woodpecker/pull/4770)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.0 [[#4767](https://github.com/woodpecker-ci/woodpecker/pull/4767)] - chore(deps): update docker.io/mysql docker tag to v9.2.0 [[#4766](https://github.com/woodpecker-ci/woodpecker/pull/4766)] - fix(deps): update module github.com/hashicorp/go-plugin to v1.6.3 [[#4765](https://github.com/woodpecker-ci/woodpecker/pull/4765)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.3 [[#4764](https://github.com/woodpecker-ci/woodpecker/pull/4764)] - fix(deps): update docker to v27.5.1+incompatible [[#4761](https://github.com/woodpecker-ci/woodpecker/pull/4761)] - chore(deps): update dependency vite to v6.0.9 [security] [[#4757](https://github.com/woodpecker-ci/woodpecker/pull/4757)] ### Misc - chore: fix some function names in comment [[#4769](https://github.com/woodpecker-ci/woodpecker/pull/4769)] ## [3.0.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.0.1) - 2025-01-20 ### ❤️ Thanks to all contributors! ❤️ @pat-s, @qwerty287, @xoxys ### 🐛 Bug Fixes - Only show visited repos and hide at all if less than 4 repos [[#4753](https://github.com/woodpecker-ci/woodpecker/pull/4753)] - Fix sql identifier escaping in datastore feed [[#4746](https://github.com/woodpecker-ci/woodpecker/pull/4746)] - Fix log folder permissions [[#4749](https://github.com/woodpecker-ci/woodpecker/pull/4749)] - Add missing error message for org_access_denied [[#4744](https://github.com/woodpecker-ci/woodpecker/pull/4744)] - Fix package configs [[#4741](https://github.com/woodpecker-ci/woodpecker/pull/4741)] ### 📚 Documentation - chore(deps): lock file maintenance [[#4751](https://github.com/woodpecker-ci/woodpecker/pull/4751)] ### 📦️ Dependency - fix(deps): update golang-packages [[#4750](https://github.com/woodpecker-ci/woodpecker/pull/4750)] - fix(deps): update dependency simple-icons to v14.3.0 [[#4739](https://github.com/woodpecker-ci/woodpecker/pull/4739)] - chore(deps): update dependency vitest to v3 [[#4736](https://github.com/woodpecker-ci/woodpecker/pull/4736)] ### Misc - fix minor tag creation for server scratch image [[#4748](https://github.com/woodpecker-ci/woodpecker/pull/4748)] - use v3 woodpecker libs [[#4742](https://github.com/woodpecker-ci/woodpecker/pull/4742)] ## [3.0.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v3.0.0) - 2025-01-18 ### ❤️ Thanks to all contributors! ❤️ @6543, @Fishbowler, @Levy-Tal, @M0Rf30, @anbraten, @cduchenoy, @cevatkerim, @fernandrone, @gedankenstuecke, @gnowland, @greenaar, @hg, @j04n-f, @jenrik, @johanneskastl, @jolheiser, @lafriks, @lukashass, @meln5674, @not-my-profile, @pat-s, @plafue, @qwerty287, @smainz, @stevapple, @tori-27, @tsufeki, @xoxys, @xtexChooser, @zc-devs ### 💥 Breaking changes - Add rootless (alpine) images [[#4617](https://github.com/woodpecker-ci/woodpecker/pull/4617)] - Unify CLI bin name [[#4673](https://github.com/woodpecker-ci/woodpecker/pull/4673)] - Support Git as only VCS [[#4346](https://github.com/woodpecker-ci/woodpecker/pull/4346)] - Add rolling semver tags, remove `latest` tag [[#4600](https://github.com/woodpecker-ci/woodpecker/pull/4600)] - Drop native Let's Encrypt support [[#4541](https://github.com/woodpecker-ci/woodpecker/pull/4541)] - Require approval for prs from public repos by default [[#4456](https://github.com/woodpecker-ci/woodpecker/pull/4456)] - Do not set empty environment variables [[#4193](https://github.com/woodpecker-ci/woodpecker/pull/4193)] - Unify cli commands and flags [[#4481](https://github.com/woodpecker-ci/woodpecker/pull/4481)] - Move pipeline logs command [[#4480](https://github.com/woodpecker-ci/woodpecker/pull/4480)] - Fix woodpecker-go repo model to match server [[#4479](https://github.com/woodpecker-ci/woodpecker/pull/4479)] - Restructure cli commands [[#4467](https://github.com/woodpecker-ci/woodpecker/pull/4467)] - Add pagination options to all supported endpoints in sdk [[#4463](https://github.com/woodpecker-ci/woodpecker/pull/4463)] - Allow to set custom trusted clone plugins [[#4352](https://github.com/woodpecker-ci/woodpecker/pull/4352)] - Add PipelineListsOptions to woodpecker-go [[#3652](https://github.com/woodpecker-ci/woodpecker/pull/3652)] - Remove `secrets` in favor of `from_secret` [[#4363](https://github.com/woodpecker-ci/woodpecker/pull/4363)] - Kubernetes | Docker: Add support for rootless images [[#4151](https://github.com/woodpecker-ci/woodpecker/pull/4151)] - Split repo trusted setting [[#4025](https://github.com/woodpecker-ci/woodpecker/pull/4025)] - Move docker resource limit settings from server to agent [[#3174](https://github.com/woodpecker-ci/woodpecker/pull/3174)] - Set `/woodpecker` as default workdir for the **woodpecker-cli** container [[#4130](https://github.com/woodpecker-ci/woodpecker/pull/4130)] - Require upgrade from 2.x [[#4112](https://github.com/woodpecker-ci/woodpecker/pull/4112)] - Don't expose task data via api [[#4108](https://github.com/woodpecker-ci/woodpecker/pull/4108)] - Remove some ci environment variables [[#3846](https://github.com/woodpecker-ci/woodpecker/pull/3846)] - Remove all default privileged plugins [[#4053](https://github.com/woodpecker-ci/woodpecker/pull/4053)] - Add option to filter secrets by plugins with specific tags [[#4069](https://github.com/woodpecker-ci/woodpecker/pull/4069)] - Remove old pipeline options [[#4016](https://github.com/woodpecker-ci/woodpecker/pull/4016)] - Remove various deprecations [[#4017](https://github.com/woodpecker-ci/woodpecker/pull/4017)] - Drop repo name fallback for hooks [[#4013](https://github.com/woodpecker-ci/woodpecker/pull/4013)] - Improve local backend detection [[#4006](https://github.com/woodpecker-ci/woodpecker/pull/4006)] - Refactor JSON and SDK fields [[#3968](https://github.com/woodpecker-ci/woodpecker/pull/3968)] - Migrate to maintained cron lib and remove seconds [[#3785](https://github.com/woodpecker-ci/woodpecker/pull/3785)] - Switch to profile-based AppArmor configuration [[#4008](https://github.com/woodpecker-ci/woodpecker/pull/4008)] - Remove Kubernetes default image pull secret name `regcred` [[#4005](https://github.com/woodpecker-ci/woodpecker/pull/4005)] - Drop "WOODPECKER_WEBHOOK_HOST" env var and adjust docs [[#3969](https://github.com/woodpecker-ci/woodpecker/pull/3969)] - Drop version in schema [[#3970](https://github.com/woodpecker-ci/woodpecker/pull/3970)] - Update docker to v27 [[#3972](https://github.com/woodpecker-ci/woodpecker/pull/3972)] - Require gitlab 12.4 [[#3966](https://github.com/woodpecker-ci/woodpecker/pull/3966)] - Migrate to maintained httpsign library [[#3839](https://github.com/woodpecker-ci/woodpecker/pull/3839)] - Remove `WOODPECKER_DEV_OAUTH_HOST` and `WOODPECKER_DEV_GITEA_OAUTH_URL` [[#3961](https://github.com/woodpecker-ci/woodpecker/pull/3961)] - Remove deprecated pipeline keywords: `pipeline:`, `platform:`, `branches:` [[#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916)] - server: remove old unused routes [[#3845](https://github.com/woodpecker-ci/woodpecker/pull/3845)] - CLI: remove step-id and add step-number as option to logs [[#3927](https://github.com/woodpecker-ci/woodpecker/pull/3927)] ### 🔒 Security - Don't log DB passwords [[#4583](https://github.com/woodpecker-ci/woodpecker/pull/4583)] - Do not log forge tokens [[#4551](https://github.com/woodpecker-ci/woodpecker/pull/4551)] - Add server config to disable user registered agents [[#4206](https://github.com/woodpecker-ci/woodpecker/pull/4206)] - chore: fix `http-proxy-middleware` CVE [[#4257](https://github.com/woodpecker-ci/woodpecker/pull/4257)] - Allow altering trusted clone plugins and filter them via tag [[#4074](https://github.com/woodpecker-ci/woodpecker/pull/4074)] - Update gitea sdk [[#4012](https://github.com/woodpecker-ci/woodpecker/pull/4012)] - Update Forgejo SDK [[#3948](https://github.com/woodpecker-ci/woodpecker/pull/3948)] ### ✨ Features - Add user as docker backend_option [[#4526](https://github.com/woodpecker-ci/woodpecker/pull/4526)] - Add dns config option to official feature set [[#4418](https://github.com/woodpecker-ci/woodpecker/pull/4418)] - Implement org/user agents [[#3539](https://github.com/woodpecker-ci/woodpecker/pull/3539)] - Replay pipeline using `cli exec` by downloading metadata [[#4103](https://github.com/woodpecker-ci/woodpecker/pull/4103)] - Update clone plugin to support sha256 [[#4136](https://github.com/woodpecker-ci/woodpecker/pull/4136)] ### 📚 Documentation - Improve 3.0.0 migration notes [[#4737](https://github.com/woodpecker-ci/woodpecker/pull/4737)] - Add docs for 3.0 [[#4705](https://github.com/woodpecker-ci/woodpecker/pull/4705)] - fix(deps): update docs npm deps non-major [[#4733](https://github.com/woodpecker-ci/woodpecker/pull/4733)] - chore(deps): update dependency @types/react to v19.0.5 [[#4714](https://github.com/woodpecker-ci/woodpecker/pull/4714)] - fix(deps): update docs npm deps non-major [[#4702](https://github.com/woodpecker-ci/woodpecker/pull/4702)] - fix(deps): update react monorepo to v19 (major) [[#4529](https://github.com/woodpecker-ci/woodpecker/pull/4529)] - Refactor `secrets` page in docs [[#4644](https://github.com/woodpecker-ci/woodpecker/pull/4644)] - fix(deps): update docs npm deps non-major [[#4661](https://github.com/woodpecker-ci/woodpecker/pull/4661)] - chore(deps): lock file maintenance [[#4647](https://github.com/woodpecker-ci/woodpecker/pull/4647)] - chore(deps): update dependency concurrently to v9.1.1 [[#4631](https://github.com/woodpecker-ci/woodpecker/pull/4631)] - Add docker in docker example to advanced usage in docs [[#4620](https://github.com/woodpecker-ci/woodpecker/pull/4620)] - fixed a typo [[#4621](https://github.com/woodpecker-ci/woodpecker/pull/4621)] - Fix misleading example in Workflow syntax/Privileged mode [[#4613](https://github.com/woodpecker-ci/woodpecker/pull/4613)] - Update docs section about "Custom clone plugins" [[#4618](https://github.com/woodpecker-ci/woodpecker/pull/4618)] - Search in plugin tags [[#4604](https://github.com/woodpecker-ci/woodpecker/pull/4604)] - chore(deps): update dependency @types/react to v18.3.18 [[#4599](https://github.com/woodpecker-ci/woodpecker/pull/4599)] - Update About [[#4555](https://github.com/woodpecker-ci/woodpecker/pull/4555)] - chore(deps): update dependency marked to v15.0.4 [[#4570](https://github.com/woodpecker-ci/woodpecker/pull/4570)] - Expand docs around the deprecation of former secret syntax [[#4561](https://github.com/woodpecker-ci/woodpecker/pull/4561)] - fix(deps): update docs npm deps non-major [[#4568](https://github.com/woodpecker-ci/woodpecker/pull/4568)] - Show client flags [[#4542](https://github.com/woodpecker-ci/woodpecker/pull/4542)] - chore(deps): update react monorepo to v19 (major) [[#4523](https://github.com/woodpecker-ci/woodpecker/pull/4523)] - chore(deps): update docs npm deps non-major [[#4519](https://github.com/woodpecker-ci/woodpecker/pull/4519)] - chore(deps): update dependency isomorphic-dompurify to v2.18.0 [[#4493](https://github.com/woodpecker-ci/woodpecker/pull/4493)] - fix(deps): update docs npm deps non-major [[#4484](https://github.com/woodpecker-ci/woodpecker/pull/4484)] - Add migration notes for restructured cli commands [[#4476](https://github.com/woodpecker-ci/woodpecker/pull/4476)] - Various fixes for `awesome.md` [[#4448](https://github.com/woodpecker-ci/woodpecker/pull/4448)] - chore(deps): update dependency isomorphic-dompurify to v2.17.0 [[#4449](https://github.com/woodpecker-ci/woodpecker/pull/4449)] - fix(deps): update docs npm deps non-major [[#4441](https://github.com/woodpecker-ci/woodpecker/pull/4441)] - chore(deps): update dependency @docusaurus/tsconfig to v3.6.2 [[#4433](https://github.com/woodpecker-ci/woodpecker/pull/4433)] - Bump minimum nodejs to v20 [[#4417](https://github.com/woodpecker-ci/woodpecker/pull/4417)] - Add microsoft teams plugin [[#4400](https://github.com/woodpecker-ci/woodpecker/pull/4400)] - fix(deps): update docs npm deps non-major [[#4394](https://github.com/woodpecker-ci/woodpecker/pull/4394)] - chore(deps): update dependency @types/node to v22 [[#4395](https://github.com/woodpecker-ci/woodpecker/pull/4395)] - chore(deps): update dependency marked to v15 [[#4396](https://github.com/woodpecker-ci/woodpecker/pull/4396)] - Kubernetes documentation enhancements [[#4374](https://github.com/woodpecker-ci/woodpecker/pull/4374)] - Podman is not (official) supported [[#4367](https://github.com/woodpecker-ci/woodpecker/pull/4367)] - Add EditorConfig-Checker Plugin to docs [[#4371](https://github.com/woodpecker-ci/woodpecker/pull/4371)] - Update netrc option description [[#4342](https://github.com/woodpecker-ci/woodpecker/pull/4342)] - Fix deployment event note [[#4283](https://github.com/woodpecker-ci/woodpecker/pull/4283)] - Improve migration notes [[#4291](https://github.com/woodpecker-ci/woodpecker/pull/4291)] - Add instructions how to build images locally [[#4277](https://github.com/woodpecker-ci/woodpecker/pull/4277)] - chore(deps): update docs npm deps non-major [[#4238](https://github.com/woodpecker-ci/woodpecker/pull/4238)] - Correct spelling [[#4279](https://github.com/woodpecker-ci/woodpecker/pull/4279)] - Add Telegram plugin [[#4229](https://github.com/woodpecker-ci/woodpecker/pull/4229)] - Remove archived plugin [[#4227](https://github.com/woodpecker-ci/woodpecker/pull/4227)] - Use "Woodpecker Authors" as copyright on website [[#4225](https://github.com/woodpecker-ci/woodpecker/pull/4225)] - chore(deps): update dependency cookie to v1 [[#4224](https://github.com/woodpecker-ci/woodpecker/pull/4224)] - fix(deps): update docs npm deps non-major [[#4221](https://github.com/woodpecker-ci/woodpecker/pull/4221)] - Fix errant apostrophe in docker-compose documentation [[#4203](https://github.com/woodpecker-ci/woodpecker/pull/4203)] - chore(deps): update dependency concurrently to v9 [[#4176](https://github.com/woodpecker-ci/woodpecker/pull/4176)] - chore(deps): update docs npm deps non-major [[#4164](https://github.com/woodpecker-ci/woodpecker/pull/4164)] - Update image filter error message [[#4143](https://github.com/woodpecker-ci/woodpecker/pull/4143)] - Docs: reference to built-in docker compose and remove deprecated version from compose examples [[#4123](https://github.com/woodpecker-ci/woodpecker/pull/4123)] - directory key is allowed for services [[#4127](https://github.com/woodpecker-ci/woodpecker/pull/4127)] - [docs] Removes dot prefix from pipeline configuration filenames [[#4105](https://github.com/woodpecker-ci/woodpecker/pull/4105)] - Use kaniko plugin in docs as example [[#4072](https://github.com/woodpecker-ci/woodpecker/pull/4072)] - Add some posts and videos [[#4070](https://github.com/woodpecker-ci/woodpecker/pull/4070)] - Move event type descriptions from Terminology to Workflow Syntax [[#4062](https://github.com/woodpecker-ci/woodpecker/pull/4062)] - Add community posts from discussions [[#4058](https://github.com/woodpecker-ci/woodpecker/pull/4058)] - Add a pull request template with some basic guidelines [[#4055](https://github.com/woodpecker-ci/woodpecker/pull/4055)] - Add examples of CI environment variable values [[#4009](https://github.com/woodpecker-ci/woodpecker/pull/4009)] - Fix inline author warning [[#4040](https://github.com/woodpecker-ci/woodpecker/pull/4040)] - Updated Secrets image filter docs [[#4028](https://github.com/woodpecker-ci/woodpecker/pull/4028)] - Update dependency marked to v14 [[#4036](https://github.com/woodpecker-ci/woodpecker/pull/4036)] - Update docs npm deps non-major [[#4033](https://github.com/woodpecker-ci/woodpecker/pull/4033)] - Overhaul README [[#3995](https://github.com/woodpecker-ci/woodpecker/pull/3995)] - fix(deps): update docs npm deps non-major [[#3990](https://github.com/woodpecker-ci/woodpecker/pull/3990)] - Add spellchecking for docs [[#3787](https://github.com/woodpecker-ci/woodpecker/pull/3787)] ### 🐛 Bug Fixes - Check organization first [[#4723](https://github.com/woodpecker-ci/woodpecker/pull/4723)] - Fix mobile view of the popup [[#4717](https://github.com/woodpecker-ci/woodpecker/pull/4717)] - Apply changed files filter to closed PR [[#4711](https://github.com/woodpecker-ci/woodpecker/pull/4711)] - Add margins to moving WP svg logo [[#4697](https://github.com/woodpecker-ci/woodpecker/pull/4697)] - Add hosts for detached steps [[#4674](https://github.com/woodpecker-ci/woodpecker/pull/4674)] - Fix addon `nil` values [[#4666](https://github.com/woodpecker-ci/woodpecker/pull/4666)] - fix cli exec statement in debug tab [[#4643](https://github.com/woodpecker-ci/woodpecker/pull/4643)] - Fix misaligned step list indentation [[#4609](https://github.com/woodpecker-ci/woodpecker/pull/4609)] - Ignore blocked pipelines for badge rendering [[#4582](https://github.com/woodpecker-ci/woodpecker/pull/4582)] - Remove related pipeline logs during pipeline deletion [[#4572](https://github.com/woodpecker-ci/woodpecker/pull/4572)] - Weakly decode backend options [[#4577](https://github.com/woodpecker-ci/woodpecker/pull/4577)] - Add client error to sdk and fix purge cli [[#4574](https://github.com/woodpecker-ci/woodpecker/pull/4574)] - Fix pipeline purge cli command [[#4569](https://github.com/woodpecker-ci/woodpecker/pull/4569)] - Fix BB ambiguous commit status key [[#4544](https://github.com/woodpecker-ci/woodpecker/pull/4544)] - fix: addon JSON pointers [[#4508](https://github.com/woodpecker-ci/woodpecker/pull/4508)] - Fix apparmorProfile being ignored when it's the only field [[#4507](https://github.com/woodpecker-ci/woodpecker/pull/4507)] - Sanitize strings in table output [[#4466](https://github.com/woodpecker-ci/woodpecker/pull/4466)] - Cleanup openapi generation [[#4331](https://github.com/woodpecker-ci/woodpecker/pull/4331)] - Support github refresh tokens [[#3811](https://github.com/woodpecker-ci/woodpecker/pull/3811)] - Fix not working overflow on repo list message [[#4420](https://github.com/woodpecker-ci/woodpecker/pull/4420)] - fix `error="io: read/write on closed pipe"` on k8s backend [[#4281](https://github.com/woodpecker-ci/woodpecker/pull/4281)] - Move update notifier dot into settings button [[#4334](https://github.com/woodpecker-ci/woodpecker/pull/4334)] - gitea: add check if pull_request webhook is missing pull info [[#4305](https://github.com/woodpecker-ci/woodpecker/pull/4305)] - Refresh token before loading branches [[#4284](https://github.com/woodpecker-ci/woodpecker/pull/4284)] - Delete GitLab webhooks with partial URL match [[#4259](https://github.com/woodpecker-ci/woodpecker/pull/4259)] - Increase `WOODPECKER_FORGE_TIMEOUT` to fix config fetching for GitLab [[#4262](https://github.com/woodpecker-ci/woodpecker/pull/4262)] - Ensure cli exec has by default not the same prefix [[#4132](https://github.com/woodpecker-ci/woodpecker/pull/4132)] - Fix repo add loading spinner [[#4135](https://github.com/woodpecker-ci/woodpecker/pull/4135)] - Fix migration registries table [[#4111](https://github.com/woodpecker-ci/woodpecker/pull/4111)] - Wait for tracer to be done before finishing workflow [[#4068](https://github.com/woodpecker-ci/woodpecker/pull/4068)] - Fix schema with detached steps [[#4066](https://github.com/woodpecker-ci/woodpecker/pull/4066)] - Fix schema with commands and entrypoint [[#4065](https://github.com/woodpecker-ci/woodpecker/pull/4065)] - Read long log lines from file storage correctly [[#4048](https://github.com/woodpecker-ci/woodpecker/pull/4048)] - Set refspec for gitlab MR [[#4021](https://github.com/woodpecker-ci/woodpecker/pull/4021)] - Set `CI_PREV_COMMIT_{SOURCE,TARGET}_BRANCH` as mentioned in the documentation [[#4001](https://github.com/woodpecker-ci/woodpecker/pull/4001)] - [Bitbucket Datacenter] Return empty list instead of null [[#4010](https://github.com/woodpecker-ci/woodpecker/pull/4010)] - Fix BB PR pipeline ref [[#3985](https://github.com/woodpecker-ci/woodpecker/pull/3985)] - Change Bitbucket PR hook to point the source branch, commit & ref [[#3965](https://github.com/woodpecker-ci/woodpecker/pull/3965)] - Add updated, merged and declined events to bb webhook activation [[#3963](https://github.com/woodpecker-ci/woodpecker/pull/3963)] - Fix login via navbar [[#3962](https://github.com/woodpecker-ci/woodpecker/pull/3962)] - Truncate creation in list [[#3952](https://github.com/woodpecker-ci/woodpecker/pull/3952)] - Fix panic if forge is unreachable [[#3944](https://github.com/woodpecker-ci/woodpecker/pull/3944)] ### 📈 Enhancement - Harmonize en texts [[#4716](https://github.com/woodpecker-ci/woodpecker/pull/4716)] - feat: add linter support for step-level `depends_on` existence [[#4657](https://github.com/woodpecker-ci/woodpecker/pull/4657)] - Reduce version redundancy [[#4707](https://github.com/woodpecker-ci/woodpecker/pull/4707)] - Add priority menu to tabs [[#4641](https://github.com/woodpecker-ci/woodpecker/pull/4641)] - feat(bitbucketdatacenter): Add support for fetching and converting projects to teams [[#4663](https://github.com/woodpecker-ci/woodpecker/pull/4663)] - Migrate from Windi to Tailwind [[#4614](https://github.com/woodpecker-ci/woodpecker/pull/4614)] - Do not start metrics collector if metrics are disabled [[#4667](https://github.com/woodpecker-ci/woodpecker/pull/4667)] - Improve badge coloring [[#4447](https://github.com/woodpecker-ci/woodpecker/pull/4447)] - Inline web helpers [[#4639](https://github.com/woodpecker-ci/woodpecker/pull/4639)] - Use filled status icons and harmonize contextually [[#4584](https://github.com/woodpecker-ci/woodpecker/pull/4584)] - Two row layout for title and context of pipeline list [[#4625](https://github.com/woodpecker-ci/woodpecker/pull/4625)] - Remove workflow-level volumes and networks [[#4636](https://github.com/woodpecker-ci/woodpecker/pull/4636)] - Migrate away from goblin [[#4624](https://github.com/woodpecker-ci/woodpecker/pull/4624)] - Use lighter red shades for error messages [[#4611](https://github.com/woodpecker-ci/woodpecker/pull/4611)] - Avoid usage of inline css style [[#4629](https://github.com/woodpecker-ci/woodpecker/pull/4629)] - Use icon sizes relative to font size [[#4575](https://github.com/woodpecker-ci/woodpecker/pull/4575)] - Use docusaurus faster [[#4528](https://github.com/woodpecker-ci/woodpecker/pull/4528)] - Add settings title action [[#4499](https://github.com/woodpecker-ci/woodpecker/pull/4499)] - Use pagination helper to list pipelines in cli [[#4478](https://github.com/woodpecker-ci/woodpecker/pull/4478)] - Some UI improvements [[#4497](https://github.com/woodpecker-ci/woodpecker/pull/4497)] - Add status filter to list pipeline API [[#4494](https://github.com/woodpecker-ci/woodpecker/pull/4494)] - Use JS-native date/time formatting [[#4488](https://github.com/woodpecker-ci/woodpecker/pull/4488)] - Add pipeline purge command to cli [[#4470](https://github.com/woodpecker-ci/woodpecker/pull/4470)] - Add option to limit the resultset returned by paginate helper [[#4475](https://github.com/woodpecker-ci/woodpecker/pull/4475)] - Add filter to list repository pipelines API [[#4416](https://github.com/woodpecker-ci/woodpecker/pull/4416)] - Increase log level when failing to fetch YAML [[#4107](https://github.com/woodpecker-ci/woodpecker/pull/4107)] - Trim space to all config flags that allow to read value from file [[#4468](https://github.com/woodpecker-ci/woodpecker/pull/4468)] - Change default icon size to 20 [[#4458](https://github.com/woodpecker-ci/woodpecker/pull/4458)] - Use same default sort for repo and org repo list [[#4461](https://github.com/woodpecker-ci/woodpecker/pull/4461)] - Add visibility icon to repo list [[#4460](https://github.com/woodpecker-ci/woodpecker/pull/4460)] - Improve tab layout and add hover effect [[#4431](https://github.com/woodpecker-ci/woodpecker/pull/4431)] - Unify pipeline status icons [[#4414](https://github.com/woodpecker-ci/woodpecker/pull/4414)] - Improve project settings descriptions [[#4410](https://github.com/woodpecker-ci/woodpecker/pull/4410)] - Add count badge to visualize counters in tab title [[#4419](https://github.com/woodpecker-ci/woodpecker/pull/4419)] - Redesign repo list and include last pipeline [[#4386](https://github.com/woodpecker-ci/woodpecker/pull/4386)] - Use KeyValueEditor for DeployPipelinePopup too [[#4412](https://github.com/woodpecker-ci/woodpecker/pull/4412)] - Use separate routes instead of anchors [[#4285](https://github.com/woodpecker-ci/woodpecker/pull/4285)] - Untangle settings / header slots [[#4403](https://github.com/woodpecker-ci/woodpecker/pull/4403)] - Fix responsiveness of the settings template [[#4383](https://github.com/woodpecker-ci/woodpecker/pull/4383)] - Use squared spinner for active pipelines [[#4379](https://github.com/woodpecker-ci/woodpecker/pull/4379)] - Add server configuration option to add default set of labels for workflows that has no labels specified [[#4326](https://github.com/woodpecker-ci/woodpecker/pull/4326)] - Add `cli lint` option to treat warnings as errors [[#4373](https://github.com/woodpecker-ci/woodpecker/pull/4373)] - Improve error message for wrong secrets / environment config [[#4359](https://github.com/woodpecker-ci/woodpecker/pull/4359)] - Improve linter messages in UI [[#4351](https://github.com/woodpecker-ci/woodpecker/pull/4351)] - Pass settings to services [[#4338](https://github.com/woodpecker-ci/woodpecker/pull/4338)] - Inline model types for migrations [[#4293](https://github.com/woodpecker-ci/woodpecker/pull/4293)] - Add options to control the database connections (open,idle,timeout) [[#4212](https://github.com/woodpecker-ci/woodpecker/pull/4212)] - Move Queue creation behind new func that evaluates queue type [[#4252](https://github.com/woodpecker-ci/woodpecker/pull/4252)] - Add additional error message on swagger v2 to v3 convert [[#4254](https://github.com/woodpecker-ci/woodpecker/pull/4254)] - Fix wording for privileged plugins linter error [[#4280](https://github.com/woodpecker-ci/woodpecker/pull/4280)] - Deprecate `secrets` [[#4235](https://github.com/woodpecker-ci/woodpecker/pull/4235)] - Agent edit/detail view: change the help url based on the backend [[#4219](https://github.com/woodpecker-ci/woodpecker/pull/4219)] - Use middleware to load org [[#4208](https://github.com/woodpecker-ci/woodpecker/pull/4208)] - Assign workflows to agents with the best label matches [[#4201](https://github.com/woodpecker-ci/woodpecker/pull/4201)] - Report custom labels set by agent admins back [[#4141](https://github.com/woodpecker-ci/woodpecker/pull/4141)] - Highlight invalid entries in manual pipeline trigger [[#4153](https://github.com/woodpecker-ci/woodpecker/pull/4153)] - Print agent labels in debug mode [[#4155](https://github.com/woodpecker-ci/woodpecker/pull/4155)] - Implement registries for Kubernetes backend [[#4092](https://github.com/woodpecker-ci/woodpecker/pull/4092)] - Correct cli exec flags and remove ineffective ones [[#4129](https://github.com/woodpecker-ci/woodpecker/pull/4129)] - Set repo user to repairing user when old user is missing [[#4128](https://github.com/woodpecker-ci/woodpecker/pull/4128)] - Restart tasks on dead agents sooner [[#4114](https://github.com/woodpecker-ci/woodpecker/pull/4114)] - Adjust cli exec metadata structure to equal server metadata [[#4119](https://github.com/woodpecker-ci/woodpecker/pull/4119)] - Allow to restart declined pipelines [[#4109](https://github.com/woodpecker-ci/woodpecker/pull/4109)] - Add indices to repo table [[#4087](https://github.com/woodpecker-ci/woodpecker/pull/4087)] - Add systemd unit files to the RPM/DEB packages [[#3986](https://github.com/woodpecker-ci/woodpecker/pull/3986)] - Duplicate key `workflow_id` in the agent logs [[#4046](https://github.com/woodpecker-ci/woodpecker/pull/4046)] - Improve error on config loading [[#4024](https://github.com/woodpecker-ci/woodpecker/pull/4024)] - Show error if secret name is missing [[#4014](https://github.com/woodpecker-ci/woodpecker/pull/4014)] - Show error returned from API [[#3980](https://github.com/woodpecker-ci/woodpecker/pull/3980)] - Move manual popup to own page [[#3981](https://github.com/woodpecker-ci/woodpecker/pull/3981)] - Fail on InvalidImageName [[#4007](https://github.com/woodpecker-ci/woodpecker/pull/4007)] - Use Bitbucket PR title for pipeline message [[#3984](https://github.com/woodpecker-ci/woodpecker/pull/3984)] - Show logs if step has error [[#3979](https://github.com/woodpecker-ci/woodpecker/pull/3979)] - Refactor docker backend and add more test coverage [[#2700](https://github.com/woodpecker-ci/woodpecker/pull/2700)] - Make cli plugin log purge recognize steps by name [[#3953](https://github.com/woodpecker-ci/woodpecker/pull/3953)] - Pin page size [[#3946](https://github.com/woodpecker-ci/woodpecker/pull/3946)] - Improve cron list [[#3947](https://github.com/woodpecker-ci/woodpecker/pull/3947)] - Add PULLREQUEST_DRONE_PULL_REQUEST drone env [[#3939](https://github.com/woodpecker-ci/woodpecker/pull/3939)] - Make agent gRPC errors distinguishable [[#3936](https://github.com/woodpecker-ci/woodpecker/pull/3936)] ### 📦️ Dependency - fix(deps): update web npm deps non-major [[#4735](https://github.com/woodpecker-ci/woodpecker/pull/4735)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.3 [[#4734](https://github.com/woodpecker-ci/woodpecker/pull/4734)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.4 [[#4732](https://github.com/woodpecker-ci/woodpecker/pull/4732)] - fix(deps): update golang-packages to v0.32.1 [[#4727](https://github.com/woodpecker-ci/woodpecker/pull/4727)] - fix(deps): update module google.golang.org/protobuf to v1.36.3 [[#4726](https://github.com/woodpecker-ci/woodpecker/pull/4726)] - fix(deps): update golang-packages [[#4725](https://github.com/woodpecker-ci/woodpecker/pull/4725)] - chore(deps): lock file maintenance [[#4721](https://github.com/woodpecker-ci/woodpecker/pull/4721)] - fix(deps): update module code.gitea.io/sdk/gitea to v0.20.0 [[#4710](https://github.com/woodpecker-ci/woodpecker/pull/4710)] - fix(deps): update dependency simple-icons to v14.2.0 [[#4709](https://github.com/woodpecker-ci/woodpecker/pull/4709)] - chore(deps): update dependency jsdom to v26 [[#4704](https://github.com/woodpecker-ci/woodpecker/pull/4704)] - fix(deps): update web npm deps non-major [[#4703](https://github.com/woodpecker-ci/woodpecker/pull/4703)] - chore(deps): update gitea/gitea docker tag to v1.23 [[#4701](https://github.com/woodpecker-ci/woodpecker/pull/4701)] - fix(deps): update golang-packages [[#4688](https://github.com/woodpecker-ci/woodpecker/pull/4688)] - fix(deps): update golang-packages [[#4678](https://github.com/woodpecker-ci/woodpecker/pull/4678)] - fix(deps): update module golang.org/x/term to v0.28.0 [[#4671](https://github.com/woodpecker-ci/woodpecker/pull/4671)] - chore(deps): lock file maintenance [[#4672](https://github.com/woodpecker-ci/woodpecker/pull/4672)] - fix(deps): update dependency simple-icons to v14.1.0 [[#4668](https://github.com/woodpecker-ci/woodpecker/pull/4668)] - fix(deps): update module golang.org/x/oauth2 to v0.25.0 [[#4665](https://github.com/woodpecker-ci/woodpecker/pull/4665)] - chore(deps): update pre-commit hook golangci/golangci-lint to v1.63.4 [[#4660](https://github.com/woodpecker-ci/woodpecker/pull/4660)] - fix(deps): update module github.com/moby/term to v0.5.2 [[#4658](https://github.com/woodpecker-ci/woodpecker/pull/4658)] - fix(deps): update web npm deps non-major [[#4659](https://github.com/woodpecker-ci/woodpecker/pull/4659)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.1 [[#4642](https://github.com/woodpecker-ci/woodpecker/pull/4642)] - fix(deps): update dependency simple-icons to v14.0.1 [[#4640](https://github.com/woodpecker-ci/woodpecker/pull/4640)] - fix(deps): update module github.com/google/go-github/v67 to v68 [[#4635](https://github.com/woodpecker-ci/woodpecker/pull/4635)] - fix(deps): update dependency vue-i18n to v11 [[#4634](https://github.com/woodpecker-ci/woodpecker/pull/4634)] - fix(deps): update dependency simple-icons to v14 [[#4633](https://github.com/woodpecker-ci/woodpecker/pull/4633)] - chore(deps): update dependency vite to v6.0.6 [[#4632](https://github.com/woodpecker-ci/woodpecker/pull/4632)] - fix(deps): update github.com/getkin/kin-openapi digest to cea0a13 [[#4630](https://github.com/woodpecker-ci/woodpecker/pull/4630)] - chore(deps): lock file maintenance [[#4540](https://github.com/woodpecker-ci/woodpecker/pull/4540)] - fix(deps): update web npm deps non-major [[#4440](https://github.com/woodpecker-ci/woodpecker/pull/4440)] - fix(deps): update golang-packages [[#4615](https://github.com/woodpecker-ci/woodpecker/pull/4615)] - fix(deps): update module gitlab.com/gitlab-org/api/client-go to v0.118.0 [[#4606](https://github.com/woodpecker-ci/woodpecker/pull/4606)] - fix(deps): update module github.com/cenkalti/backoff/v4 to v5 [[#4601](https://github.com/woodpecker-ci/woodpecker/pull/4601)] - fix(deps): update golang-packages [[#4586](https://github.com/woodpecker-ci/woodpecker/pull/4586)] - fix(deps): update module golang.org/x/net to v0.33.0 [security] [[#4585](https://github.com/woodpecker-ci/woodpecker/pull/4585)] - fix(deps): update golang-packages [[#4579](https://github.com/woodpecker-ci/woodpecker/pull/4579)] - Replace discontinued mitchellh/mapstructure by maintained fork [[#4573](https://github.com/woodpecker-ci/woodpecker/pull/4573)] - chore(deps): update docker.io/woodpeckerci/plugin-codecov docker tag to v2.1.6 [[#4566](https://github.com/woodpecker-ci/woodpecker/pull/4566)] - fix(deps): update github.com/muesli/termenv digest to 8c990cd [[#4565](https://github.com/woodpecker-ci/woodpecker/pull/4565)] - fix(deps): update module google.golang.org/grpc to v1.69.0 [[#4563](https://github.com/woodpecker-ci/woodpecker/pull/4563)] - fix(deps): update golang-packages [[#4553](https://github.com/woodpecker-ci/woodpecker/pull/4553)] - Update kin-openapi [[#4560](https://github.com/woodpecker-ci/woodpecker/pull/4560)] - fix(deps): update module golang.org/x/crypto to v0.31.0 [security] [[#4557](https://github.com/woodpecker-ci/woodpecker/pull/4557)] - fix(deps): update golang-packages [[#4546](https://github.com/woodpecker-ci/woodpecker/pull/4546)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.1.0 [[#4536](https://github.com/woodpecker-ci/woodpecker/pull/4536)] - chore(deps): update docker.io/curlimages/curl docker tag to v8.11.0 [[#4530](https://github.com/woodpecker-ci/woodpecker/pull/4530)] - fix(deps): update golang-packages [[#4496](https://github.com/woodpecker-ci/woodpecker/pull/4496)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.1.0 [[#4524](https://github.com/woodpecker-ci/woodpecker/pull/4524)] - chore(deps): update docker.io/woodpeckerci/plugin-prettier docker tag to v1 [[#4522](https://github.com/woodpecker-ci/woodpecker/pull/4522)] - chore(deps): update docker.io/alpine docker tag to v3.21 [[#4520](https://github.com/woodpecker-ci/woodpecker/pull/4520)] - chore(deps): update dependency vite to v6 [[#4485](https://github.com/woodpecker-ci/woodpecker/pull/4485)] - chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3 [[#4506](https://github.com/woodpecker-ci/woodpecker/pull/4506)] - chore(deps): lock file maintenance [[#4502](https://github.com/woodpecker-ci/woodpecker/pull/4502)] - chore(deps): lock file maintenance [[#4501](https://github.com/woodpecker-ci/woodpecker/pull/4501)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.3 [[#4495](https://github.com/woodpecker-ci/woodpecker/pull/4495)] - fix(deps): update golang-packages [[#4477](https://github.com/woodpecker-ci/woodpecker/pull/4477)] - fix(deps): update dependency @vueuse/core to v12 [[#4486](https://github.com/woodpecker-ci/woodpecker/pull/4486)] - fix(deps): update module github.com/google/go-github/v66 to v67 [[#4487](https://github.com/woodpecker-ci/woodpecker/pull/4487)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.2 [[#4483](https://github.com/woodpecker-ci/woodpecker/pull/4483)] - chore(deps): update pre-commit hook golangci/golangci-lint to v1.62.2 [[#4482](https://github.com/woodpecker-ci/woodpecker/pull/4482)] - fix(deps): update golang-packages [[#4452](https://github.com/woodpecker-ci/woodpecker/pull/4452)] - chore(deps): lock file maintenance [[#4453](https://github.com/woodpecker-ci/woodpecker/pull/4453)] - fix(deps): update golang-packages [[#4411](https://github.com/woodpecker-ci/woodpecker/pull/4411)] - chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.43.0 [[#4443](https://github.com/woodpecker-ci/woodpecker/pull/4443)] - chore(deps): update postgres docker tag to v17.2 [[#4442](https://github.com/woodpecker-ci/woodpecker/pull/4442)] - chore(deps): lock file maintenance [[#4435](https://github.com/woodpecker-ci/woodpecker/pull/4435)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.3.0 [[#4434](https://github.com/woodpecker-ci/woodpecker/pull/4434)] - chore(deps): update web npm deps non-major [[#4432](https://github.com/woodpecker-ci/woodpecker/pull/4432)] - fix(deps): update golang-packages [[#4401](https://github.com/woodpecker-ci/woodpecker/pull/4401)] - chore(deps): lock file maintenance [[#4402](https://github.com/woodpecker-ci/woodpecker/pull/4402)] - chore(deps): update web npm deps non-major [[#4391](https://github.com/woodpecker-ci/woodpecker/pull/4391)] - fix(deps): update dependency @intlify/unplugin-vue-i18n to v6 [[#4397](https://github.com/woodpecker-ci/woodpecker/pull/4397)] - chore(deps): update pre-commit hook golangci/golangci-lint to v1.62.0 [[#4390](https://github.com/woodpecker-ci/woodpecker/pull/4390)] - chore(deps): update postgres docker tag to v17.1 [[#4389](https://github.com/woodpecker-ci/woodpecker/pull/4389)] - chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.23.x [[#4388](https://github.com/woodpecker-ci/woodpecker/pull/4388)] - chore(config): migrate renovate config [[#4296](https://github.com/woodpecker-ci/woodpecker/pull/4296)] - chore(deps): update docker.io/woodpeckerci/plugin-trivy docker tag to v1.2.0 [[#4289](https://github.com/woodpecker-ci/woodpecker/pull/4289)] - chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.23.x [[#4282](https://github.com/woodpecker-ci/woodpecker/pull/4282)] - fix(deps): update golang-packages [[#4251](https://github.com/woodpecker-ci/woodpecker/pull/4251)] - fix(deps): update web npm deps non-major [[#4258](https://github.com/woodpecker-ci/woodpecker/pull/4258)] - chore(deps): update web npm deps non-major [[#4250](https://github.com/woodpecker-ci/woodpecker/pull/4250)] - chore(deps): update node.js to v23 [[#4239](https://github.com/woodpecker-ci/woodpecker/pull/4239)] - chore(deps): update web npm deps non-major [[#4237](https://github.com/woodpecker-ci/woodpecker/pull/4237)] - chore(deps): update docker.io/mysql docker tag to v9.1.0 [[#4236](https://github.com/woodpecker-ci/woodpecker/pull/4236)] - fix(deps): update dependency simple-icons to v13.14.0 [[#4226](https://github.com/woodpecker-ci/woodpecker/pull/4226)] - fix(deps): update web npm deps non-major [[#4223](https://github.com/woodpecker-ci/woodpecker/pull/4223)] - fix(deps): update golang-packages [[#4215](https://github.com/woodpecker-ci/woodpecker/pull/4215)] - fix(deps): update golang-packages [[#4210](https://github.com/woodpecker-ci/woodpecker/pull/4210)] - fix(deps): update module github.com/google/go-github/v65 to v66 [[#4205](https://github.com/woodpecker-ci/woodpecker/pull/4205)] - fix(deps): update dependency vue-i18n to v10.0.4 [[#4200](https://github.com/woodpecker-ci/woodpecker/pull/4200)] - chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v5 [[#4192](https://github.com/woodpecker-ci/woodpecker/pull/4192)] - fix(deps): update dependency simple-icons to v13.13.0 [[#4196](https://github.com/woodpecker-ci/woodpecker/pull/4196)] - chore(deps): lock file maintenance [[#4186](https://github.com/woodpecker-ci/woodpecker/pull/4186)] - chore(deps): update web npm deps non-major [[#4174](https://github.com/woodpecker-ci/woodpecker/pull/4174)] - chore(deps): update docker.io/postgres docker tag to v17 [[#4179](https://github.com/woodpecker-ci/woodpecker/pull/4179)] - fix(deps): update dependency @intlify/unplugin-vue-i18n to v5 [[#4183](https://github.com/woodpecker-ci/woodpecker/pull/4183)] - fix(deps): update dependency @vueuse/core to v11 [[#4184](https://github.com/woodpecker-ci/woodpecker/pull/4184)] - chore(deps): update docker.io/woodpeckerci/plugin-codecov docker tag to v2.1.5 [[#4167](https://github.com/woodpecker-ci/woodpecker/pull/4167)] - fix(deps): update module github.com/google/go-github/v64 to v65 [[#4185](https://github.com/woodpecker-ci/woodpecker/pull/4185)] - chore(deps): update docker.io/mysql docker tag to v9 [[#4178](https://github.com/woodpecker-ci/woodpecker/pull/4178)] - chore(deps): update docker.io/alpine docker tag to v3.20 [[#4169](https://github.com/woodpecker-ci/woodpecker/pull/4169)] - fix(deps): update github.com/urfave/cli/v3 digest to 20ef97b [[#4166](https://github.com/woodpecker-ci/woodpecker/pull/4166)] - chore(deps): update docker.io/woodpeckerci/plugin-surge-preview docker tag to v1.3.2 [[#4168](https://github.com/woodpecker-ci/woodpecker/pull/4168)] - chore(deps): update woodpeckerci/plugin-release docker tag to v0.2.1 [[#4175](https://github.com/woodpecker-ci/woodpecker/pull/4175)] - chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v2 [[#4182](https://github.com/woodpecker-ci/woodpecker/pull/4182)] - fix(deps): update github.com/muesli/termenv digest to 82936c5 [[#4165](https://github.com/woodpecker-ci/woodpecker/pull/4165)] - chore(deps): update postgres docker tag to v17 [[#4181](https://github.com/woodpecker-ci/woodpecker/pull/4181)] - chore(deps): update pre-commit non-major [[#4173](https://github.com/woodpecker-ci/woodpecker/pull/4173)] - chore(deps): update docker.io/golang docker tag to v1.23 [[#4170](https://github.com/woodpecker-ci/woodpecker/pull/4170)] - chore(deps): update node.js to v22 [[#4180](https://github.com/woodpecker-ci/woodpecker/pull/4180)] - fix(deps): update golang-packages [[#4161](https://github.com/woodpecker-ci/woodpecker/pull/4161)] - chore(deps): update dependency @antfu/eslint-config to v3 [[#4095](https://github.com/woodpecker-ci/woodpecker/pull/4095)] - chore(deps): update dependency jsdom to v25 [[#4094](https://github.com/woodpecker-ci/woodpecker/pull/4094)] - chore(deps): update docker.io/golang docker tag to v1.23 [[#4081](https://github.com/woodpecker-ci/woodpecker/pull/4081)] - chore(deps): update docker.io/woodpeckerci/plugin-prettier docker tag to v0.2.0 [[#4082](https://github.com/woodpecker-ci/woodpecker/pull/4082)] - fix(deps): update module github.com/google/go-github/v63 to v64 [[#4073](https://github.com/woodpecker-ci/woodpecker/pull/4073)] - fix(deps): update golang-packages [[#4059](https://github.com/woodpecker-ci/woodpecker/pull/4059)] - Update github.com/urfave/cli/v3 digest to fc07a8c [[#4043](https://github.com/woodpecker-ci/woodpecker/pull/4043)] - Update woodpeckerci/plugin-git Docker tag to v2.5.2 [[#4041](https://github.com/woodpecker-ci/woodpecker/pull/4041)] - Update web npm deps non-major [[#4034](https://github.com/woodpecker-ci/woodpecker/pull/4034)] - Update dependency simple-icons to v13 [[#4037](https://github.com/woodpecker-ci/woodpecker/pull/4037)] - chore(deps): lock file maintenance [[#3991](https://github.com/woodpecker-ci/woodpecker/pull/3991)] - fix(deps): update golang-packages [[#3958](https://github.com/woodpecker-ci/woodpecker/pull/3958)] ### Misc - Use mirror.gcr.io as `trivy` registry [[#4729](https://github.com/woodpecker-ci/woodpecker/pull/4729)] - Add docs-dependencies target to makefile [[#4719](https://github.com/woodpecker-ci/woodpecker/pull/4719)] - Move link checks into cron-curated issue dashboard [[#4515](https://github.com/woodpecker-ci/woodpecker/pull/4515)] - Remove `renovate` branch triggers [[#4437](https://github.com/woodpecker-ci/woodpecker/pull/4437)] - Dont run pipeline on push events to renovate branches [[#4406](https://github.com/woodpecker-ci/woodpecker/pull/4406)] - Harden and correct fifo task queue tests [[#4377](https://github.com/woodpecker-ci/woodpecker/pull/4377)] - Use release-helper for release/* branches [[#4301](https://github.com/woodpecker-ci/woodpecker/pull/4301)] - Fix renovate support for `xgo` [[#4276](https://github.com/woodpecker-ci/woodpecker/pull/4276)] - Improve nix development environment [[#4256](https://github.com/woodpecker-ci/woodpecker/pull/4256)] - [pre-commit.ci] pre-commit autoupdate [[#4209](https://github.com/woodpecker-ci/woodpecker/pull/4209)] - Add `.lycheeignore` [[#4154](https://github.com/woodpecker-ci/woodpecker/pull/4154)] - Add eslint-plugin-promise back [[#4022](https://github.com/woodpecker-ci/woodpecker/pull/4022)] - Improve wording [[#3951](https://github.com/woodpecker-ci/woodpecker/pull/3951)] - Fix typos and optimize wording [[#3940](https://github.com/woodpecker-ci/woodpecker/pull/3940)] ## [2.7.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.7.2) - 2024-11-03 ### Important To secure your instance, set `WOODPECKER_PLUGINS_PRIVILEGED` to only allow specific versions of the `woodpeckerci/plugin-docker-buildx` plugin, use version 5.0.0 or above. This prevents older, potentially unstable versions from being privileged. For example, to allow only version 5.0.0, use: ```bash WOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0 ``` To allow multiple versions, you can separate them with commas: ```bash WOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0,woodpeckerci/plugin-docker-buildx:5.1.0 ``` This setup ensures only specified, stable plugin versions are given privileged access. Read more about it in [#4213](https://github.com/woodpecker-ci/woodpecker/pull/4213) ### ❤️ Thanks to all contributors! ❤️ @6543, @anbraten, @j04n-f, @pat-s, @qwerty287 ### 🔒 Security - Chore(deps): update dependency vite to v5.4.6 [security] ([#4163](https://github.com/woodpecker-ci/woodpecker/pull/4163)) [[#4187](https://github.com/woodpecker-ci/woodpecker/pull/4187)] ### 🐛 Bug Fixes - Don't parse forge config files multiple times if no error occured ([#4272](https://github.com/woodpecker-ci/woodpecker/pull/4272)) [[#4273](https://github.com/woodpecker-ci/woodpecker/pull/4273)] - Fix repo/owner parsing for gitlab ([#4255](https://github.com/woodpecker-ci/woodpecker/pull/4255)) [[#4261](https://github.com/woodpecker-ci/woodpecker/pull/4261)] - Run queue.process() in background [[#4115](https://github.com/woodpecker-ci/woodpecker/pull/4115)] - Only update agent.LastWork if not done recently ([#4031](https://github.com/woodpecker-ci/woodpecker/pull/4031)) [[#4100](https://github.com/woodpecker-ci/woodpecker/pull/4100)] ### Misc - Backport JS dependency updates [[#4189](https://github.com/woodpecker-ci/woodpecker/pull/4189)] ## [2.7.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.7.1) - 2024-09-07 ### ❤️ Thanks to all contributors! ❤️ @6543, @anbraten, @j04n-f, @qwerty287 ### 🔒 Security - Lint privileged plugin match and allow to be set empty [[#4084](https://github.com/woodpecker-ci/woodpecker/pull/4084)] - Allow admins to specify privileged plugins by name **and tag** [[#4076](https://github.com/woodpecker-ci/woodpecker/pull/4076)] - Warn if using secrets/env with plugin [[#4039](https://github.com/woodpecker-ci/woodpecker/pull/4039)] ### 🐛 Bug Fixes - Set refspec for gitlab MR [[#4021](https://github.com/woodpecker-ci/woodpecker/pull/4021)] - Change Bitbucket PR hook to point the source branch, commit & ref [[#3965](https://github.com/woodpecker-ci/woodpecker/pull/3965)] - Add updated, merged and declined events to bb webhook activation [[#3963](https://github.com/woodpecker-ci/woodpecker/pull/3963)] - Fix login via navbar [[#3962](https://github.com/woodpecker-ci/woodpecker/pull/3962)] - Fix panic if forge is unreachable [[#3944](https://github.com/woodpecker-ci/woodpecker/pull/3944)] - Fix org settings page [[#4093](https://github.com/woodpecker-ci/woodpecker/pull/4093)] ### Misc - Bump github.com/docker/docker from v24.0.9 to v24.0.9+30 [[#4077](https://github.com/woodpecker-ci/woodpecker/pull/4077)] ## [2.7.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.7.0) - 2024-07-18 ### ❤️ Thanks to all contributors! ❤️ @6543, @anbraten, @dvjn, @hhamalai, @lafriks, @pat-s, @qwerty287, @smainz, @tongjicoder, @zc-devs ### 🔒 Security - Add blocklist of environment variables who could alter execution of plugins [[#3934](https://github.com/woodpecker-ci/woodpecker/pull/3934)] - Make sure plugins only mount the workspace base in a predefinde location [[#3933](https://github.com/woodpecker-ci/woodpecker/pull/3933)] - Disallow to set arbitrary environments for plugins [[#3909](https://github.com/woodpecker-ci/woodpecker/pull/3909)] - Use proper oauth state [[#3847](https://github.com/woodpecker-ci/woodpecker/pull/3847)] - Enhance token checking [[#3842](https://github.com/woodpecker-ci/woodpecker/pull/3842)] - Bump github.com/hashicorp/go-retryablehttp v0.7.5 -> v0.7.7 [[#3834](https://github.com/woodpecker-ci/woodpecker/pull/3834)] ### ✨ Features - Gracefully shutdown server [[#3896](https://github.com/woodpecker-ci/woodpecker/pull/3896)] - Gracefully shutdown agent [[#3895](https://github.com/woodpecker-ci/woodpecker/pull/3895)] - Convert urls in logs to links [[#3904](https://github.com/woodpecker-ci/woodpecker/pull/3904)] - Allow login using multiple forges [[#3822](https://github.com/woodpecker-ci/woodpecker/pull/3822)] - Global and organization registries [[#1672](https://github.com/woodpecker-ci/woodpecker/pull/1672)] - Cli get repo from git remote [[#3830](https://github.com/woodpecker-ci/woodpecker/pull/3830)] - Add api for forges [[#3733](https://github.com/woodpecker-ci/woodpecker/pull/3733)] ### 📈 Enhancement - Cli fix pipeline logs [[#3913](https://github.com/woodpecker-ci/woodpecker/pull/3913)] - Migrate to github.com/urfave/cli/v3 [[#2951](https://github.com/woodpecker-ci/woodpecker/pull/2951)] - Allow to change the working directory also for plugins and services [[#3914](https://github.com/woodpecker-ci/woodpecker/pull/3914)] - Remove `unplugin-icons` [[#3809](https://github.com/woodpecker-ci/woodpecker/pull/3809)] - Release windows binaries as zip file [[#3906](https://github.com/woodpecker-ci/woodpecker/pull/3906)] - Convert to openapi 3.0 [[#3897](https://github.com/woodpecker-ci/woodpecker/pull/3897)] - Enhance pipeline list [[#3898](https://github.com/woodpecker-ci/woodpecker/pull/3898)] - Add user registries UI [[#3888](https://github.com/woodpecker-ci/woodpecker/pull/3888)] - Sort users by login [[#3891](https://github.com/woodpecker-ci/woodpecker/pull/3891)] - Exclude dummy backend in production [[#3877](https://github.com/woodpecker-ci/woodpecker/pull/3877)] - Fix deploy task env [[#3878](https://github.com/woodpecker-ci/woodpecker/pull/3878)] - Get default branch and show message in pipeline list [[#3867](https://github.com/woodpecker-ci/woodpecker/pull/3867)] - Add timestamp for last work done by agent [[#3844](https://github.com/woodpecker-ci/woodpecker/pull/3844)] - Adjust logger types [[#3859](https://github.com/woodpecker-ci/woodpecker/pull/3859)] - Cleanup state reporting [[#3850](https://github.com/woodpecker-ci/woodpecker/pull/3850)] - Unify DB tables/columns [[#3806](https://github.com/woodpecker-ci/woodpecker/pull/3806)] - Let webhook pass on pipeline parsing error [[#3829](https://github.com/woodpecker-ci/woodpecker/pull/3829)] - Exclude mocks from release build [[#3831](https://github.com/woodpecker-ci/woodpecker/pull/3831)] - K8s secrets reference from step [[#3655](https://github.com/woodpecker-ci/woodpecker/pull/3655)] ### 🐛 Bug Fixes - Handle empty repositories in gitea when listing PRs [[#3925](https://github.com/woodpecker-ci/woodpecker/pull/3925)] - Update alpine package dep for docker images [[#3917](https://github.com/woodpecker-ci/woodpecker/pull/3917)] - Don't report error if agent was terminated gracefully [[#3894](https://github.com/woodpecker-ci/woodpecker/pull/3894)] - Let agents continuously report their health [[#3893](https://github.com/woodpecker-ci/woodpecker/pull/3893)] - Ignore warnings for cli exec [[#3868](https://github.com/woodpecker-ci/woodpecker/pull/3868)] - Correct favicon states [[#3832](https://github.com/woodpecker-ci/woodpecker/pull/3832)] - Cleanup of the login flow and tests [[#3810](https://github.com/woodpecker-ci/woodpecker/pull/3810)] - Fix newlines in logs [[#3808](https://github.com/woodpecker-ci/woodpecker/pull/3808)] - Fix authentication error handling [[#3807](https://github.com/woodpecker-ci/woodpecker/pull/3807)] ### 📚 Documentation - Streamline docs for new users [[#3803](https://github.com/woodpecker-ci/woodpecker/pull/3803)] - Add mastodon verification [[#3843](https://github.com/woodpecker-ci/woodpecker/pull/3843)] - chore(deps): update docs npm deps non-major [[#3837](https://github.com/woodpecker-ci/woodpecker/pull/3837)] - fix(deps): update docs npm deps non-major [[#3824](https://github.com/woodpecker-ci/woodpecker/pull/3824)] - Add openSUSE package [[#3800](https://github.com/woodpecker-ci/woodpecker/pull/3800)] - chore(deps): update docs npm deps non-major [[#3798](https://github.com/woodpecker-ci/woodpecker/pull/3798)] - Add "Docker Tags" Plugin [[#3796](https://github.com/woodpecker-ci/woodpecker/pull/3796)] - chore(deps): update dependency marked to v13 [[#3792](https://github.com/woodpecker-ci/woodpecker/pull/3792)] - chore: fix some comments [[#3788](https://github.com/woodpecker-ci/woodpecker/pull/3788)] ### Misc - chore(deps): update web npm deps non-major [[#3930](https://github.com/woodpecker-ci/woodpecker/pull/3930)] - chore(deps): update dependency vitest to v2 [[#3905](https://github.com/woodpecker-ci/woodpecker/pull/3905)] - fix(deps): update module github.com/google/go-github/v62 to v63 [[#3910](https://github.com/woodpecker-ci/woodpecker/pull/3910)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v4.1.0 [[#3908](https://github.com/woodpecker-ci/woodpecker/pull/3908)] - Update plugin-git and add renovate trigger [[#3901](https://github.com/woodpecker-ci/woodpecker/pull/3901)] - chore(deps): update docker.io/mstruebing/editorconfig-checker docker tag to v3.0.3 [[#3903](https://github.com/woodpecker-ci/woodpecker/pull/3903)] - fix(deps): update golang-packages [[#3875](https://github.com/woodpecker-ci/woodpecker/pull/3875)] - chore(deps): lock file maintenance [[#3876](https://github.com/woodpecker-ci/woodpecker/pull/3876)] - [pre-commit.ci] pre-commit autoupdate [[#3862](https://github.com/woodpecker-ci/woodpecker/pull/3862)] - Add dummy backend [[#3820](https://github.com/woodpecker-ci/woodpecker/pull/3820)] - chore(deps): update dependency replace-in-file to v8 [[#3852](https://github.com/woodpecker-ci/woodpecker/pull/3852)] - Update forgejo sdk [[#3840](https://github.com/woodpecker-ci/woodpecker/pull/3840)] - chore(deps): lock file maintenance [[#3838](https://github.com/woodpecker-ci/woodpecker/pull/3838)] - Allow to set dist dir using env var [[#3814](https://github.com/woodpecker-ci/woodpecker/pull/3814)] - chore(deps): lock file maintenance [[#3805](https://github.com/woodpecker-ci/woodpecker/pull/3805)] - chore(deps): update docker.io/lycheeverse/lychee docker tag to v0.15.1 [[#3797](https://github.com/woodpecker-ci/woodpecker/pull/3797)] ## [2.6.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.6.1) - 2024-07-19 ### 🔒 Security - Add blocklist of environment variables who could alter execution of plugins [[#3934](https://github.com/woodpecker-ci/woodpecker/pull/3934)] - Make sure plugins only mount the workspace base in a predefinde location [[#3933](https://github.com/woodpecker-ci/woodpecker/pull/3933)] - Disalow to set arbitrary environments for plugins [[#3909](https://github.com/woodpecker-ci/woodpecker/pull/3909)] - Bump trivy plugin version and remove unused variable [[#3833](https://github.com/woodpecker-ci/woodpecker/pull/3833)] ### 🐛 Bug Fixes - Let webhook pass on pipeline parsion error [[#3829](https://github.com/woodpecker-ci/woodpecker/pull/3829)] - Fix newlines in logs [[#3808](https://github.com/woodpecker-ci/woodpecker/pull/3808)] ## [2.6.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.6.0) - 2024-06-13 ### ❤️ Thanks to all contributors! ❤️ @6543, @anbraten, @jcgl17, @pat-s, @qwerty287, @s00500, @wez, @zc-devs ### 🔒 Security - Bump trivy plugin version and remove unused variable [[#3759](https://github.com/woodpecker-ci/woodpecker/pull/3759)] ### ✨ Features - Allow to store logs in files [[#3568](https://github.com/woodpecker-ci/woodpecker/pull/3568)] - Native forgejo support [[#3684](https://github.com/woodpecker-ci/woodpecker/pull/3684)] ### 🐛 Bug Fixes - Add release event to webhooks [[#3784](https://github.com/woodpecker-ci/woodpecker/pull/3784)] - Respect cli argument when checking docker backend availability [[#3770](https://github.com/woodpecker-ci/woodpecker/pull/3770)] - Fix repo creation [[#3756](https://github.com/woodpecker-ci/woodpecker/pull/3756)] - Fix config loading of cli [[#3764](https://github.com/woodpecker-ci/woodpecker/pull/3764)] - Fix missing WOODPECKER_BITBUCKET_DC_URL [[#3761](https://github.com/woodpecker-ci/woodpecker/pull/3761)] - Correct repo repair success message in cli [[#3757](https://github.com/woodpecker-ci/woodpecker/pull/3757)] ### 📈 Enhancement - Improve step logging [[#3722](https://github.com/woodpecker-ci/woodpecker/pull/3722)] - chore(deps): update dependency eslint to v9 [[#3594](https://github.com/woodpecker-ci/woodpecker/pull/3594)] - Show workflow names if there are multiple configs [[#3767](https://github.com/woodpecker-ci/woodpecker/pull/3767)] - Use http constants [[#3766](https://github.com/woodpecker-ci/woodpecker/pull/3766)] - Spellcheck "server/*" [[#3753](https://github.com/woodpecker-ci/woodpecker/pull/3753)] - Agent-wide node selector [[#3608](https://github.com/woodpecker-ci/woodpecker/pull/3608)] ### 📚 Documentation - Remove misleading crontab guru suggestion from docs [[#3781](https://github.com/woodpecker-ci/woodpecker/pull/3781)] - Add documentation for KUBERNETES_SERVICE_HOST in Agent [[#3747](https://github.com/woodpecker-ci/woodpecker/pull/3747)] - Remove web.archive.org workaround in docs [[#3771](https://github.com/woodpecker-ci/woodpecker/pull/3771)] - Serve plugin icons locally [[#3768](https://github.com/woodpecker-ci/woodpecker/pull/3768)] - Docs: update local backend page [[#3765](https://github.com/woodpecker-ci/woodpecker/pull/3765)] - Remove old docs versions [[#3743](https://github.com/woodpecker-ci/woodpecker/pull/3743)] - Merge release plugins [[#3752](https://github.com/woodpecker-ci/woodpecker/pull/3752)] - Split FAQ [[#3746](https://github.com/woodpecker-ci/woodpecker/pull/3746)] ### Misc - Update nix flake [[#3780](https://github.com/woodpecker-ci/woodpecker/pull/3780)] - chore(deps): lock file maintenance [[#3783](https://github.com/woodpecker-ci/woodpecker/pull/3783)] - chore(deps): update pre-commit hook golangci/golangci-lint to v1.59.1 [[#3782](https://github.com/woodpecker-ci/woodpecker/pull/3782)] - fix(deps): update codeberg.org/mvdkleijn/forgejo-sdk/forgejo digest to 168c988 [[#3776](https://github.com/woodpecker-ci/woodpecker/pull/3776)] - chore(deps): lock file maintenance [[#3750](https://github.com/woodpecker-ci/woodpecker/pull/3750)] - chore(deps): update gitea/gitea docker tag to v1.22 [[#3749](https://github.com/woodpecker-ci/woodpecker/pull/3749)] - Fix setting name [[#3744](https://github.com/woodpecker-ci/woodpecker/pull/3744)] ## [2.5.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.5.0) - 2024-06-01 ### ❤️ Thanks to all contributors! ❤️ @6543, @Andre601, @Elara6331, @OCram85, @anbraten, @aumetra, @da-Kai, @dominic-p, @dvjn, @eliasscosta, @fernandrone, @linghuying, @manuelluis, @nemunaire, @pat-s, @qwerty287, @sinlov, @stevapple, @xoxys, @zc-devs ### 🔒 Security - bump golang.org/x/net to v0.24.0 [[#3628](https://github.com/woodpecker-ci/woodpecker/pull/3628)] ### ✨ Features - Add DeletePipeline API [[#3506](https://github.com/woodpecker-ci/woodpecker/pull/3506)] - CLI: remove step logs [[#3458](https://github.com/woodpecker-ci/woodpecker/pull/3458)] - Step logs removing API and Button [[#3451](https://github.com/woodpecker-ci/woodpecker/pull/3451)] ### 📚 Documentation - Create 2.5 docs [[#3732](https://github.com/woodpecker-ci/woodpecker/pull/3732)] - Fix spelling in README [[#3741](https://github.com/woodpecker-ci/woodpecker/pull/3741)] - chore: fix some comments [[#3740](https://github.com/woodpecker-ci/woodpecker/pull/3740)] - Add "Is It Up Yet?" Plugin [[#3731](https://github.com/woodpecker-ci/woodpecker/pull/3731)] - Remove discord as official community channel [[#3717](https://github.com/woodpecker-ci/woodpecker/pull/3717)] - Add Gitea Package plugin [[#3707](https://github.com/woodpecker-ci/woodpecker/pull/3707)] - Add documentation for setting Kubernetes labels and annotations [[#3687](https://github.com/woodpecker-ci/woodpecker/pull/3687)] - Remove broken link to gobook.io [[#3694](https://github.com/woodpecker-ci/woodpecker/pull/3694)] - docs: add `Gitea publisher-golang` plugin [[#3691](https://github.com/woodpecker-ci/woodpecker/pull/3691)] - Add Ansible+Woodpecker blog post [[#3685](https://github.com/woodpecker-ci/woodpecker/pull/3685)] - Clarify info on failing workflows/Steps [[#3679](https://github.com/woodpecker-ci/woodpecker/pull/3679)] - Add discord plugin [[#3662](https://github.com/woodpecker-ci/woodpecker/pull/3662)] - chore(deps): update dependency trim to v1 [[#3658](https://github.com/woodpecker-ci/woodpecker/pull/3658)] - chore(deps): update dependency got to v14 [[#3657](https://github.com/woodpecker-ci/woodpecker/pull/3657)] - Fail on broken anchors [[#3644](https://github.com/woodpecker-ci/woodpecker/pull/3644)] - Fix step syntax in docs [[#3635](https://github.com/woodpecker-ci/woodpecker/pull/3635)] - chore(deps): update docs npm deps non-major [[#3632](https://github.com/woodpecker-ci/woodpecker/pull/3632)] - Add Twine plugin [[#3619](https://github.com/woodpecker-ci/woodpecker/pull/3619)] - Fix docs [[#3615](https://github.com/woodpecker-ci/woodpecker/pull/3615)] - Document how to enable parallel step exec for all steps [[#3605](https://github.com/woodpecker-ci/woodpecker/pull/3605)] - Update dependency @types/marked to v6 [[#3544](https://github.com/woodpecker-ci/woodpecker/pull/3544)] - Update docs npm deps non-major [[#3485](https://github.com/woodpecker-ci/woodpecker/pull/3485)] - Docs updates and fixes [[#3535](https://github.com/woodpecker-ci/woodpecker/pull/3535)] ### 🐛 Bug Fixes - Fix privileged steps in kubernetes [[#3711](https://github.com/woodpecker-ci/woodpecker/pull/3711)] - Check for error in repo middleware [[#3688](https://github.com/woodpecker-ci/woodpecker/pull/3688)] - Fix parent pipeline number env on restarts [[#3683](https://github.com/woodpecker-ci/woodpecker/pull/3683)] - Fix bitbucket dir fetching [[#3668](https://github.com/woodpecker-ci/woodpecker/pull/3668)] - Sanitize tag ref for gitea/forgejo [[#3664](https://github.com/woodpecker-ci/woodpecker/pull/3664)] - Fix secret loading [[#3620](https://github.com/woodpecker-ci/woodpecker/pull/3620)] - fix cli config loading and correct comment [[#3618](https://github.com/woodpecker-ci/woodpecker/pull/3618)] - Handle ImagePullBackOff pod status [[#3580](https://github.com/woodpecker-ci/woodpecker/pull/3580)] - Apply skip ci filter only on push events [[#3612](https://github.com/woodpecker-ci/woodpecker/pull/3612)] - agent: Continue to retry indefinitely [[#3599](https://github.com/woodpecker-ci/woodpecker/pull/3599)] - Fix cli version comparison and improve setup [[#3518](https://github.com/woodpecker-ci/woodpecker/pull/3518)] - Fix flag name [[#3534](https://github.com/woodpecker-ci/woodpecker/pull/3534)] ### 📈 Enhancement - Use IDs for tokens [[#3695](https://github.com/woodpecker-ci/woodpecker/pull/3695)] - Lint go code with cspell [[#3706](https://github.com/woodpecker-ci/woodpecker/pull/3706)] - Replace duplicated strings [[#3710](https://github.com/woodpecker-ci/woodpecker/pull/3710)] - Cleanup server env settings [[#3670](https://github.com/woodpecker-ci/woodpecker/pull/3670)] - Setting for empty commits on path condition [[#3708](https://github.com/woodpecker-ci/woodpecker/pull/3708)] - Lint file names and directories via cSpell too [[#3703](https://github.com/woodpecker-ci/woodpecker/pull/3703)] - Make retry count of config fetching form forge configure [[#3699](https://github.com/woodpecker-ci/woodpecker/pull/3699)] - Ability to set pod annotations and labels from step [[#3609](https://github.com/woodpecker-ci/woodpecker/pull/3609)] - Support github deploy task [[#3512](https://github.com/woodpecker-ci/woodpecker/pull/3512)] - Rework entrypoints [[#3269](https://github.com/woodpecker-ci/woodpecker/pull/3269)] - Add cli output handlers [[#3660](https://github.com/woodpecker-ci/woodpecker/pull/3660)] - Cleanup api docs and ts api-client options [[#3663](https://github.com/woodpecker-ci/woodpecker/pull/3663)] - Split client into multiple files and add more tests [[#3647](https://github.com/woodpecker-ci/woodpecker/pull/3647)] - Add filter options to GetPipelines API [[#3645](https://github.com/woodpecker-ci/woodpecker/pull/3645)] - Deprecate environment filter and improve errors [[#3634](https://github.com/woodpecker-ci/woodpecker/pull/3634)] - Add task details to queue info in woodpecker-go [[#3636](https://github.com/woodpecker-ci/woodpecker/pull/3636)] - Use forge from db [[#1417](https://github.com/woodpecker-ci/woodpecker/pull/1417)] - Remove review button from approval view [[#3617](https://github.com/woodpecker-ci/woodpecker/pull/3617)] - Rework addons (use rpc) [[#3268](https://github.com/woodpecker-ci/woodpecker/pull/3268)] - Allow to disable deployments [[#3570](https://github.com/woodpecker-ci/woodpecker/pull/3570)] - Add flag to only access public repositories on GitHub [[#3566](https://github.com/woodpecker-ci/woodpecker/pull/3566)] - Add `runtimeClassName` in Kubernetes backend options [[#3474](https://github.com/woodpecker-ci/woodpecker/pull/3474)] - Remove unused cache properties [[#3567](https://github.com/woodpecker-ci/woodpecker/pull/3567)] - Allow separate gitea oauth URL [[#3513](https://github.com/woodpecker-ci/woodpecker/pull/3513)] - Add option to set the local repository path to the cli command exec. [[#3524](https://github.com/woodpecker-ci/woodpecker/pull/3524)] ### Misc - chore(deps): update pre-commit non-major [[#3736](https://github.com/woodpecker-ci/woodpecker/pull/3736)] - chore(deps): update docker.io/alpine docker tag to v3.20 [[#3735](https://github.com/woodpecker-ci/woodpecker/pull/3735)] - fix(deps): update module github.com/google/go-github/v61 to v62 [[#3730](https://github.com/woodpecker-ci/woodpecker/pull/3730)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v4 [[#3729](https://github.com/woodpecker-ci/woodpecker/pull/3729)] - chore(deps): update docker.io/mstruebing/editorconfig-checker docker tag to v3 [[#3728](https://github.com/woodpecker-ci/woodpecker/pull/3728)] - chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.2 [[#3724](https://github.com/woodpecker-ci/woodpecker/pull/3724)] - fix(deps): update golang-packages [[#3713](https://github.com/woodpecker-ci/woodpecker/pull/3713)] - chore(deps): update postgres docker tag to v16.3 [[#3719](https://github.com/woodpecker-ci/woodpecker/pull/3719)] - chore(deps): update docker.io/appleboy/drone-discord docker tag to v1.3.2 [[#3718](https://github.com/woodpecker-ci/woodpecker/pull/3718)] - Added steps to reproduce and expected behavior in bug_report.yaml [[#3714](https://github.com/woodpecker-ci/woodpecker/pull/3714)] - flake: add flake-utils import and use eachDefaultSystem [[#3704](https://github.com/woodpecker-ci/woodpecker/pull/3704)] - Add nix flake for dev shell [[#3702](https://github.com/woodpecker-ci/woodpecker/pull/3702)] - Skip golangci in pre-commit.ci [[#3692](https://github.com/woodpecker-ci/woodpecker/pull/3692)] - chore(deps): update woodpeckerci/plugin-github-release docker tag to v1.2.0 [[#3690](https://github.com/woodpecker-ci/woodpecker/pull/3690)] - Switch back to upstream xgo image [[#3682](https://github.com/woodpecker-ci/woodpecker/pull/3682)] - Allow running tests on arm64 runners [[#2605](https://github.com/woodpecker-ci/woodpecker/pull/2605)] - chore(deps): update node.js to v22 [[#3659](https://github.com/woodpecker-ci/woodpecker/pull/3659)] - chore(deps): lock file maintenance [[#3656](https://github.com/woodpecker-ci/woodpecker/pull/3656)] - Add make target for spellcheck [[#3648](https://github.com/woodpecker-ci/woodpecker/pull/3648)] - chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.1 [[#3641](https://github.com/woodpecker-ci/woodpecker/pull/3641)] - chore(deps): update web npm deps non-major [[#3640](https://github.com/woodpecker-ci/woodpecker/pull/3640)] - chore(deps): update web npm deps non-major [[#3631](https://github.com/woodpecker-ci/woodpecker/pull/3631)] - Use our github-release plugin [[#3624](https://github.com/woodpecker-ci/woodpecker/pull/3624)] - chore(deps): lock file maintenance [[#3622](https://github.com/woodpecker-ci/woodpecker/pull/3622)] - Fix spellcheck and enable more dirs [[#3603](https://github.com/woodpecker-ci/woodpecker/pull/3603)] - Update docker.io/golang Docker tag to v1.22.2 [[#3596](https://github.com/woodpecker-ci/woodpecker/pull/3596)] - Update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 [[#3597](https://github.com/woodpecker-ci/woodpecker/pull/3597)] - Update module github.com/google/go-github/v60 to v61 [[#3595](https://github.com/woodpecker-ci/woodpecker/pull/3595)] - Update pre-commit hook golangci/golangci-lint to v1.57.2 [[#3575](https://github.com/woodpecker-ci/woodpecker/pull/3575)] - Update docker.io/woodpeckerci/plugin-docker-buildx Docker tag to v3.2.1 [[#3574](https://github.com/woodpecker-ci/woodpecker/pull/3574)] - Update web npm deps non-major [[#3576](https://github.com/woodpecker-ci/woodpecker/pull/3576)] - Update dependency @intlify/unplugin-vue-i18n to v4 [[#3572](https://github.com/woodpecker-ci/woodpecker/pull/3572)] - Update golang (packages) [[#3564](https://github.com/woodpecker-ci/woodpecker/pull/3564)] - Update dependency typescript to v5.4.3 [[#3563](https://github.com/woodpecker-ci/woodpecker/pull/3563)] - Lock file maintenance [[#3562](https://github.com/woodpecker-ci/woodpecker/pull/3562)] - Update pre-commit non-major [[#3556](https://github.com/woodpecker-ci/woodpecker/pull/3556)] - Update web npm deps non-major [[#3549](https://github.com/woodpecker-ci/woodpecker/pull/3549)] - Update dependency @types/node-emoji to v2 [[#3545](https://github.com/woodpecker-ci/woodpecker/pull/3545)] - Update golang (packages) [[#3543](https://github.com/woodpecker-ci/woodpecker/pull/3543)] - Lock file maintenance [[#3541](https://github.com/woodpecker-ci/woodpecker/pull/3541)] - Update docker.io/woodpeckerci/plugin-docker-buildx Docker tag to v3.2.0 [[#3540](https://github.com/woodpecker-ci/woodpecker/pull/3540)] ## [2.4.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.4.1) - 2024-03-20 ### ❤️ Thanks to all contributors! ❤️ @manuelluis, @qwerty287, @xoxys ### 🔒 Security - Only allow to deploy from push, tag and release [[#3522](https://github.com/woodpecker-ci/woodpecker/pull/3522)] ### 🐛 Bug Fixes - Exclude setup from cli command exec. [[#3523](https://github.com/woodpecker-ci/woodpecker/pull/3523)] - Fix uppercased env [[#3516](https://github.com/woodpecker-ci/woodpecker/pull/3516)] - Fix env schema [[#3514](https://github.com/woodpecker-ci/woodpecker/pull/3514)] ### Misc - Temp pin golangci version in makefile [[#3520](https://github.com/woodpecker-ci/woodpecker/pull/3520)] ## [2.4.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.4.0) - 2024-03-19 ### ❤️ Thanks to all contributors! ❤️ @6543, @Ray-D-Song, @anbraten, @eliasscosta, @fernandrone, @kjuulh, @kytta, @langecode, @lukashass, @qwerty287, @rockdrilla, @sinlov, @smainz, @xoxys, @zc-devs, @zowhoey ### 🔒 Security - Improve security context handling [[#3482](https://github.com/woodpecker-ci/woodpecker/pull/3482)] - fix(deps): update module github.com/moby/moby to v24.0.9+incompatible [[#3323](https://github.com/woodpecker-ci/woodpecker/pull/3323)] ### ✨ Features - Cli setup command [[#3384](https://github.com/woodpecker-ci/woodpecker/pull/3384)] - Add bitbucket datacenter (server) support [[#2503](https://github.com/woodpecker-ci/woodpecker/pull/2503)] - Cli updater [[#3382](https://github.com/woodpecker-ci/woodpecker/pull/3382)] ### 📚 Documentation - Delete docs for v0.15.x [[#3508](https://github.com/woodpecker-ci/woodpecker/pull/3508)] - Add deployment plugin [[#3495](https://github.com/woodpecker-ci/woodpecker/pull/3495)] - Bump follow-redirects and fix broken anchors [[#3488](https://github.com/woodpecker-ci/woodpecker/pull/3488)] - fix: plugin doc page not found [[#3480](https://github.com/woodpecker-ci/woodpecker/pull/3480)] - Documentation improvements [[#3376](https://github.com/woodpecker-ci/woodpecker/pull/3376)] - fix(deps): update docs npm deps non-major [[#3455](https://github.com/woodpecker-ci/woodpecker/pull/3455)] - Add "Sonatype Nexus" plugin [[#3446](https://github.com/woodpecker-ci/woodpecker/pull/3446)] - Add blog post [[#3439](https://github.com/woodpecker-ci/woodpecker/pull/3439)] - Add "Gradle Wrapper Validation" plugin [[#3435](https://github.com/woodpecker-ci/woodpecker/pull/3435)] - Add blog post [[#3410](https://github.com/woodpecker-ci/woodpecker/pull/3410)] - Extend core ideas documentation [[#3405](https://github.com/woodpecker-ci/woodpecker/pull/3405)] - docs: fix contributions link [[#3363](https://github.com/woodpecker-ci/woodpecker/pull/3363)] - Update/fix some docs [[#3359](https://github.com/woodpecker-ci/woodpecker/pull/3359)] - chore(deps): update dependency marked to v12 [[#3325](https://github.com/woodpecker-ci/woodpecker/pull/3325)] ### 🐛 Bug Fixes - Fix skip setup for some general cli commands [[#3498](https://github.com/woodpecker-ci/woodpecker/pull/3498)] - Move generic agent flags to cmd/agent/core [[#3484](https://github.com/woodpecker-ci/woodpecker/pull/3484)] - Fix usage of WOODPECKER_DATABASE_DATASOURCE_FILE [[#3404](https://github.com/woodpecker-ci/woodpecker/pull/3404)] - Set pull-request id and labels on pr-closed event [[#3442](https://github.com/woodpecker-ci/woodpecker/pull/3442)] - Update org name on login [[#3409](https://github.com/woodpecker-ci/woodpecker/pull/3409)] - Do not alter secret key upper-/lowercase [[#3375](https://github.com/woodpecker-ci/woodpecker/pull/3375)] - fix: can't run multiple services on k8s [[#3395](https://github.com/woodpecker-ci/woodpecker/pull/3395)] - Fix agent polling [[#3378](https://github.com/woodpecker-ci/woodpecker/pull/3378)] - Remove empty strings from slice before parsing agent config [[#3387](https://github.com/woodpecker-ci/woodpecker/pull/3387)] - Set correct link for commit [[#3368](https://github.com/woodpecker-ci/woodpecker/pull/3368)] - Fix schema links [[#3369](https://github.com/woodpecker-ci/woodpecker/pull/3369)] - Fix correctly handle gitlab pr closed events [[#3362](https://github.com/woodpecker-ci/woodpecker/pull/3362)] - fix: update schema event_enum to remove error warning when.event [[#3357](https://github.com/woodpecker-ci/woodpecker/pull/3357)] - Fix version check on next [[#3340](https://github.com/woodpecker-ci/woodpecker/pull/3340)] - Ignore gitlab merge request events without code changes [[#3338](https://github.com/woodpecker-ci/woodpecker/pull/3338)] - Ignore gitlab push events without commits [[#3339](https://github.com/woodpecker-ci/woodpecker/pull/3339)] - Consider gitlab inherited permissions [[#3308](https://github.com/woodpecker-ci/woodpecker/pull/3308)] - fix: agent panic when node is terminated during step execution [[#3331](https://github.com/woodpecker-ci/woodpecker/pull/3331)] ### 📈 Enhancement - Enable golangci linter gomnd [[#3171](https://github.com/woodpecker-ci/woodpecker/pull/3171)] - Apply "grpcnotrace" go build tag [[#3448](https://github.com/woodpecker-ci/woodpecker/pull/3448)] - Simplify store interfaces [[#3437](https://github.com/woodpecker-ci/woodpecker/pull/3437)] - Deprecate alternative names on secrets [[#3406](https://github.com/woodpecker-ci/woodpecker/pull/3406)] - Store workflows/steps for blocked pipeline [[#2757](https://github.com/woodpecker-ci/woodpecker/pull/2757)] - Parse email from Gitea webhook [[#3420](https://github.com/woodpecker-ci/woodpecker/pull/3420)] - Replace http types on forge interface [[#3374](https://github.com/woodpecker-ci/woodpecker/pull/3374)] - Prevent agent deletion when it's still running tasks [[#3377](https://github.com/woodpecker-ci/woodpecker/pull/3377)] - Refactor internal services [[#915](https://github.com/woodpecker-ci/woodpecker/pull/915)] - Lint for event filter and deprecate `exclude` [[#3222](https://github.com/woodpecker-ci/woodpecker/pull/3222)] - Allow editing all environment variables in pipeline popups [[#3314](https://github.com/woodpecker-ci/woodpecker/pull/3314)] - Parse backend options in backend [[#3227](https://github.com/woodpecker-ci/woodpecker/pull/3227)] - Make agent usable for external backends [[#3270](https://github.com/woodpecker-ci/woodpecker/pull/3270)] - Add no branches text [[#3312](https://github.com/woodpecker-ci/woodpecker/pull/3312)] - Add loading spinner to repo list [[#3310](https://github.com/woodpecker-ci/woodpecker/pull/3310)] ### Misc - Post on mastodon when releasing a new version [[#3509](https://github.com/woodpecker-ci/woodpecker/pull/3509)] - chore(deps): update dependency alpine_3_18/ca-certificates to v20240226 [[#3501](https://github.com/woodpecker-ci/woodpecker/pull/3501)] - fix(deps): update module github.com/google/go-github/v59 to v60 [[#3493](https://github.com/woodpecker-ci/woodpecker/pull/3493)] - fix(deps): update dependency @intlify/unplugin-vue-i18n to v3 [[#3492](https://github.com/woodpecker-ci/woodpecker/pull/3492)] - chore(deps): update dependency vue-tsc to v2 [[#3491](https://github.com/woodpecker-ci/woodpecker/pull/3491)] - chore(deps): update dependency eslint-config-airbnb-typescript to v18 [[#3490](https://github.com/woodpecker-ci/woodpecker/pull/3490)] - chore(deps): update web npm deps non-major [[#3489](https://github.com/woodpecker-ci/woodpecker/pull/3489)] - fix(deps): update golang (packages) [[#3486](https://github.com/woodpecker-ci/woodpecker/pull/3486)] - fix(deps): update module google.golang.org/protobuf to v1.33.0 [security] [[#3487](https://github.com/woodpecker-ci/woodpecker/pull/3487)] - chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.22.1 [[#3476](https://github.com/woodpecker-ci/woodpecker/pull/3476)] - chore(deps): update docker.io/golang docker tag to v1.22.1 [[#3475](https://github.com/woodpecker-ci/woodpecker/pull/3475)] - Update prettier version [[#3471](https://github.com/woodpecker-ci/woodpecker/pull/3471)] - chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.0 [[#3464](https://github.com/woodpecker-ci/woodpecker/pull/3464)] - chore(deps): lock file maintenance [[#3465](https://github.com/woodpecker-ci/woodpecker/pull/3465)] - chore(deps): update postgres docker tag to v16.2 [[#3461](https://github.com/woodpecker-ci/woodpecker/pull/3461)] - chore(deps): update lycheeverse/lychee docker tag to v0.14.3 [[#3429](https://github.com/woodpecker-ci/woodpecker/pull/3429)] - fix(deps): update golang (packages) [[#3430](https://github.com/woodpecker-ci/woodpecker/pull/3430)] - More `when` filters [[#3407](https://github.com/woodpecker-ci/woodpecker/pull/3407)] - Apply `documentation`/`ui` label to corresponding renovate updates [[#3400](https://github.com/woodpecker-ci/woodpecker/pull/3400)] - chore(deps): update dependency eslint-plugin-simple-import-sort to v12 [[#3396](https://github.com/woodpecker-ci/woodpecker/pull/3396)] - chore(deps): update typescript-eslint monorepo to v7 (major) [[#3397](https://github.com/woodpecker-ci/woodpecker/pull/3397)] - fix(deps): update module github.com/google/go-github/v58 to v59 [[#3398](https://github.com/woodpecker-ci/woodpecker/pull/3398)] - chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.22.0 [[#3392](https://github.com/woodpecker-ci/woodpecker/pull/3392)] - chore(deps): update docker.io/golang docker tag [[#3391](https://github.com/woodpecker-ci/woodpecker/pull/3391)] - fix(deps): update golang (packages) [[#3393](https://github.com/woodpecker-ci/woodpecker/pull/3393)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v3.1.0 [[#3394](https://github.com/woodpecker-ci/woodpecker/pull/3394)] - Add link checking [[#3371](https://github.com/woodpecker-ci/woodpecker/pull/3371)] - Apply `dependencies` label to all PRs [[#3358](https://github.com/woodpecker-ci/woodpecker/pull/3358)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v3.0.1 [[#3324](https://github.com/woodpecker-ci/woodpecker/pull/3324)] ## [2.3.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.3.0) - 2024-01-31 ### ❤️ Thanks to all contributors! ❤️ @anbraten, @HerHde, @qwerty287, @pat-s, @renovate[bot], @lukashass, @zc-devs, @Alonsohhl, @healdropper, @eliasscosta, @runephilosof-karnovgroup ### ✨ Features - Add release event [[#3226](https://github.com/woodpecker-ci/woodpecker/pull/3226)] ### 📚 Documentation - Add release types [[#3303](https://github.com/woodpecker-ci/woodpecker/pull/3303)] - Add opencollective footer [[#3281](https://github.com/woodpecker-ci/woodpecker/pull/3281)] - Use array syntax in docs [[#3242](https://github.com/woodpecker-ci/woodpecker/pull/3242)] ### 🐛 Bug Fixes - Fix Gitpod: Gitea auth token creation [[#3299](https://github.com/woodpecker-ci/woodpecker/pull/3299)] - Fix agent updating [[#3287](https://github.com/woodpecker-ci/woodpecker/pull/3287)] - Sanitize pod's step label [[#3275](https://github.com/woodpecker-ci/woodpecker/pull/3275)] - Pipeline errors must be an array [[#3276](https://github.com/woodpecker-ci/woodpecker/pull/3276)] - fix bitbucket SSO using UUID from bitbucket api response as ForgeRemoteID [[#3265](https://github.com/woodpecker-ci/woodpecker/pull/3265)] - fix: bug pod service without label service [[#3256](https://github.com/woodpecker-ci/woodpecker/pull/3256)] - Fix disabling PRs [[#3258](https://github.com/woodpecker-ci/woodpecker/pull/3258)] - fix: bug annotations [[#3255](https://github.com/woodpecker-ci/woodpecker/pull/3255)] ### 📈 Enhancement - Update theme on system color mode change [[#3296](https://github.com/woodpecker-ci/woodpecker/pull/3296)] - Improve secrets availability checks [[#3271](https://github.com/woodpecker-ci/woodpecker/pull/3271)] - Load more pipeline log lines (500 => 5000) [[#3212](https://github.com/woodpecker-ci/woodpecker/pull/3212)] - Clean up models [[#3228](https://github.com/woodpecker-ci/woodpecker/pull/3228)] ### Misc - chore(deps): update docker.io/techknowlogick/xgo docker tag to go-1.21.6 [[#3294](https://github.com/woodpecker-ci/woodpecker/pull/3294)] - fix(deps): update docs npm deps non-major [[#3295](https://github.com/woodpecker-ci/woodpecker/pull/3295)] - Remove deprecated `group` from config [[#3289](https://github.com/woodpecker-ci/woodpecker/pull/3289)] - Add spellcheck config [[#3018](https://github.com/woodpecker-ci/woodpecker/pull/3018)] - fix(deps): update golang (packages) [[#3284](https://github.com/woodpecker-ci/woodpecker/pull/3284)] - chore(deps): lock file maintenance [[#3274](https://github.com/woodpecker-ci/woodpecker/pull/3274)] - chore(deps): update web npm deps non-major [[#3273](https://github.com/woodpecker-ci/woodpecker/pull/3273)] - Pin prettier version [[#3260](https://github.com/woodpecker-ci/woodpecker/pull/3260)] - Fix prettier [[#3259](https://github.com/woodpecker-ci/woodpecker/pull/3259)] - Update UI building in Makefile [[#3250](https://github.com/woodpecker-ci/woodpecker/pull/3250)] ## [2.2.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/2.2.2) - 2024-01-21 ### ❤️ Thanks to all contributors! ❤️ @6543 ### Misc - build: fix nfpm path for server binary [[#3246](https://github.com/woodpecker-ci/woodpecker/pull/3246)] ## [2.2.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.2.1) - 2024-01-21 ### ❤️ Thanks to all contributors! ❤️ @6543 ### 🐛 Bug Fixes - Add gitea/forgejo driver check, to handle ErrUnknownVersion error [[#3243](https://github.com/woodpecker-ci/woodpecker/pull/3243)] ### Misc - Build tarball for distribution packages [[#3244](https://github.com/woodpecker-ci/woodpecker/pull/3244)] ## [2.2.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.2.0) - 2024-01-21 ### ❤️ Thanks to all contributors! ❤️ @qwerty287, @zc-devs, @renovate[bot], @mzampetakis, @healdropper, @6543, @micash545, @xoxys, @pat-s, @miry, @lukashass, @lafriks, @pre-commit-ci[bot], @anbraten, @andyhan, @KamilaBorowska ### 🔒 Security - Update web dependencies [[#3234](https://github.com/woodpecker-ci/woodpecker/pull/3234)] ### ✨ Features - Support custom steps entrypoint [[#2985](https://github.com/woodpecker-ci/woodpecker/pull/2985)] ### 📚 Documentation - Add 2.2 docs [[#3237](https://github.com/woodpecker-ci/woodpecker/pull/3237)] - Fix/improve issue templates [[#3232](https://github.com/woodpecker-ci/woodpecker/pull/3232)] - Delete `FUNDING.yaml` [[#3193](https://github.com/woodpecker-ci/woodpecker/pull/3193)] - Remove contributing/security to use globally defined [[#3192](https://github.com/woodpecker-ci/woodpecker/pull/3192)] - Add "Kaniko" Plugin [[#3183](https://github.com/woodpecker-ci/woodpecker/pull/3183)] - Document core development ideas [[#3184](https://github.com/woodpecker-ci/woodpecker/pull/3184)] - Add continuous deployment cookbook [[#3098](https://github.com/woodpecker-ci/woodpecker/pull/3098)] - Make k8s backend configuration docs in the same format as others [[#3081](https://github.com/woodpecker-ci/woodpecker/pull/3081)] - Hide backend config options from TOC [[#3126](https://github.com/woodpecker-ci/woodpecker/pull/3126)] - Add X/Twitter account [[#3127](https://github.com/woodpecker-ci/woodpecker/pull/3127)] - Add ansible plugin [[#3115](https://github.com/woodpecker-ci/woodpecker/pull/3115)] - Format depends_on example [[#3118](https://github.com/woodpecker-ci/woodpecker/pull/3118)] - Use WOODPECKER_AGENT_SECRET instead of deprecated alternative [[#3103](https://github.com/woodpecker-ci/woodpecker/pull/3103)] - Add Reviewdog ESLint plugin [[#3102](https://github.com/woodpecker-ci/woodpecker/pull/3102)] - Mark local backend as stable [[#3088](https://github.com/woodpecker-ci/woodpecker/pull/3088)] - Update Owners 2024 [[#3075](https://github.com/woodpecker-ci/woodpecker/pull/3075)] - Add reviewdog golangci plugin [[#3080](https://github.com/woodpecker-ci/woodpecker/pull/3080)] - Add Codeberg Pages Deploy plugin to plugins list [[#3054](https://github.com/woodpecker-ci/woodpecker/pull/3054)] ### 🐛 Bug Fixes - Fixed Pods creation of WP services [[#3236](https://github.com/woodpecker-ci/woodpecker/pull/3236)] - Fix Bitbucket get pull requests that ignores pagination [[#3235](https://github.com/woodpecker-ci/woodpecker/pull/3235)] - Make PipelineConfig unique again [[#3215](https://github.com/woodpecker-ci/woodpecker/pull/3215)] - Fix feed sorting [[#3155](https://github.com/woodpecker-ci/woodpecker/pull/3155)] - Step status update dont set to running again once it got stoped [[#3151](https://github.com/woodpecker-ci/woodpecker/pull/3151)] - Use step uuid instead of name in GRPC status calls [[#3143](https://github.com/woodpecker-ci/woodpecker/pull/3143)] - Use UUID instead of step name where possible [[#3136](https://github.com/woodpecker-ci/woodpecker/pull/3136)] - Use step type to detect services in Kubernetes backend [[#3141](https://github.com/woodpecker-ci/woodpecker/pull/3141)] - Fix config base64 parsing to utf-8 [[#3110](https://github.com/woodpecker-ci/woodpecker/pull/3110)] - Pin Gitea version [[#3104](https://github.com/woodpecker-ci/woodpecker/pull/3104)] - Fix step `depends_on` as string in schema [[#3099](https://github.com/woodpecker-ci/woodpecker/pull/3099)] - Fix slice unmarshaling [[#3097](https://github.com/woodpecker-ci/woodpecker/pull/3097)] - Allow PR secrets to be used on close [[#3084](https://github.com/woodpecker-ci/woodpecker/pull/3084)] - make event in pipeline schema also a constraint_list [[#3082](https://github.com/woodpecker-ci/woodpecker/pull/3082)] - Fix badge's repoUrl with rootpath [[#3076](https://github.com/woodpecker-ci/woodpecker/pull/3076)] - Load changed files for closed PR [[#3067](https://github.com/woodpecker-ci/woodpecker/pull/3067)] - Fix build output paths [[#3065](https://github.com/woodpecker-ci/woodpecker/pull/3065)] - Fix `when` and `depends_on` [[#3063](https://github.com/woodpecker-ci/woodpecker/pull/3063)] - Fix DAG cycle detection [[#3049](https://github.com/woodpecker-ci/woodpecker/pull/3049)] - Fix duplicated icons [[#3045](https://github.com/woodpecker-ci/woodpecker/pull/3045)] ### 📈 Enhancement - Retrieve all user repo perms with a single API call [[#3211](https://github.com/woodpecker-ci/woodpecker/pull/3211)] - Secured kubernetes backend configuration [[#3204](https://github.com/woodpecker-ci/woodpecker/pull/3204)] - Use `assert` for tests [[#3201](https://github.com/woodpecker-ci/woodpecker/pull/3201)] - Replace `goimports` with `gci` [[#3202](https://github.com/woodpecker-ci/woodpecker/pull/3202)] - Remove multipart logger [[#3200](https://github.com/woodpecker-ci/woodpecker/pull/3200)] - Added protocol in port configuration [[#2993](https://github.com/woodpecker-ci/woodpecker/pull/2993)] - Kubernetes AppArmor and seccomp [[#3123](https://github.com/woodpecker-ci/woodpecker/pull/3123)] - `cli exec`: let override existing environment values but print a warning [[#3140](https://github.com/woodpecker-ci/woodpecker/pull/3140)] - Enable golangci linter forcetypeassert [[#3168](https://github.com/woodpecker-ci/woodpecker/pull/3168)] - Enable golangci linter contextcheck [[#3170](https://github.com/woodpecker-ci/woodpecker/pull/3170)] - Remove panic recovering [[#3162](https://github.com/woodpecker-ci/woodpecker/pull/3162)] - More docker backend test remove more undocumented [[#3156](https://github.com/woodpecker-ci/woodpecker/pull/3156)] - Lowercase all log strings [[#3173](https://github.com/woodpecker-ci/woodpecker/pull/3173)] - Cleanups + prefer .yaml [[#3069](https://github.com/woodpecker-ci/woodpecker/pull/3069)] - Use UUID as podName and cleanup arguments for Kubernetes backend [[#3135](https://github.com/woodpecker-ci/woodpecker/pull/3135)] - Enable golangci linter stylecheck [[#3167](https://github.com/woodpecker-ci/woodpecker/pull/3167)] - Clean up logging [[#3161](https://github.com/woodpecker-ci/woodpecker/pull/3161)] - Enable `gocritic` and don't ignore globally [[#3159](https://github.com/woodpecker-ci/woodpecker/pull/3159)] - Remove steps for publishing release branches [[#3125](https://github.com/woodpecker-ci/woodpecker/pull/3125)] - Enable `nolintlint` [[#3158](https://github.com/woodpecker-ci/woodpecker/pull/3158)] - Enable some linters [[#3129](https://github.com/woodpecker-ci/woodpecker/pull/3129)] - Use name in backend types instead of alias [[#3142](https://github.com/woodpecker-ci/woodpecker/pull/3142)] - Make service icon rotate [[#3149](https://github.com/woodpecker-ci/woodpecker/pull/3149)] - Add step name as label to docker containers [[#3137](https://github.com/woodpecker-ci/woodpecker/pull/3137)] - Use js-base64 on pipeline log page [[#3146](https://github.com/woodpecker-ci/woodpecker/pull/3146)] - Flexible image pull secret reference [[#3016](https://github.com/woodpecker-ci/woodpecker/pull/3016)] - Always show pipeline step list [[#3114](https://github.com/woodpecker-ci/woodpecker/pull/3114)] - Add loading spinner and no pull request text [[#3113](https://github.com/woodpecker-ci/woodpecker/pull/3113)] - Fix timeout settings contrast [[#3112](https://github.com/woodpecker-ci/woodpecker/pull/3112)] - Unfold workflow when opening via URL [[#3106](https://github.com/woodpecker-ci/woodpecker/pull/3106)] - Remove env argument of addons [[#3100](https://github.com/woodpecker-ci/woodpecker/pull/3100)] - Move `cmd/common` to `shared` [[#3092](https://github.com/woodpecker-ci/woodpecker/pull/3092)] - use semver for version comparsion [[#3042](https://github.com/woodpecker-ci/woodpecker/pull/3042)] - Extend create plugin docs [[#3062](https://github.com/woodpecker-ci/woodpecker/pull/3062)] - Remove old files [[#3077](https://github.com/woodpecker-ci/woodpecker/pull/3077)] - Indicate if step is service [[#3078](https://github.com/woodpecker-ci/woodpecker/pull/3078)] - Add imports checks to linter [[#3056](https://github.com/woodpecker-ci/woodpecker/pull/3056)] - Remove workflow version again [[#3052](https://github.com/woodpecker-ci/woodpecker/pull/3052)] - Add option to disable version check in admin web UI [[#3040](https://github.com/woodpecker-ci/woodpecker/pull/3040)] ### Misc - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v3 [[#3229](https://github.com/woodpecker-ci/woodpecker/pull/3229)] - Docs: Fix expression syntax docs url [[#3208](https://github.com/woodpecker-ci/woodpecker/pull/3208)] - Add schema test for depends_on [[#3205](https://github.com/woodpecker-ci/woodpecker/pull/3205)] - chore(deps): lock file maintenance [[#3190](https://github.com/woodpecker-ci/woodpecker/pull/3190)] - Do not run prettier with pre-commit [[#3196](https://github.com/woodpecker-ci/woodpecker/pull/3196)] - fix(deps): update module github.com/google/go-github/v57 to v58 [[#3187](https://github.com/woodpecker-ci/woodpecker/pull/3187)] - chore(deps): update docker.io/golang docker tag to v1.21.6 [[#3189](https://github.com/woodpecker-ci/woodpecker/pull/3189)] - chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx [[#3186](https://github.com/woodpecker-ci/woodpecker/pull/3186)] - fix(deps): update golang (packages) [[#3185](https://github.com/woodpecker-ci/woodpecker/pull/3185)] - declare different when statements once and reuse them [[#3176](https://github.com/woodpecker-ci/woodpecker/pull/3176)] - Add `make clean-all` [[#3152](https://github.com/woodpecker-ci/woodpecker/pull/3152)] - Fix `version.json` updates [[#3057](https://github.com/woodpecker-ci/woodpecker/pull/3057)] - [pre-commit.ci] pre-commit autoupdate [[#3101](https://github.com/woodpecker-ci/woodpecker/pull/3101)] - Update dependency @vitejs/plugin-vue to v5 [[#3074](https://github.com/woodpecker-ci/woodpecker/pull/3074)] - Use CI vars for plugin [[#3061](https://github.com/woodpecker-ci/woodpecker/pull/3061)] - Use `yamllint` [[#3066](https://github.com/woodpecker-ci/woodpecker/pull/3066)] - Use dag in ci config [[#3010](https://github.com/woodpecker-ci/woodpecker/pull/3010)] ## [2.1.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.1.1) - 2023-12-27 ### ❤️ Thanks to all contributors! ❤️ @6543, @andyhan, @qwerty287 ### 🐛 Bug Fixes - trim v on version check [[#3039](https://github.com/woodpecker-ci/woodpecker/pull/3039)] - make backend step dag generation deterministic [[#3037](https://github.com/woodpecker-ci/woodpecker/pull/3037)] - Fix showing wrong badge url when root path is set [[#3033](https://github.com/woodpecker-ci/woodpecker/pull/3033)] - Fix docs label [[#3028](https://github.com/woodpecker-ci/woodpecker/pull/3028)] ### 📚 Documentation - Update go report card badge [[#3029](https://github.com/woodpecker-ci/woodpecker/pull/3029)] ### Misc - Add some tests [[#3030](https://github.com/woodpecker-ci/woodpecker/pull/3030)] ## [2.1.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.1.0) - 2023-12-26 ### ❤️ Thanks to all contributors! ❤️ @anbraten, @lukashass, @qwerty287, @6543, @Lerentis, @renovate[bot], @zc-devs, @johanvdw, @lafriks, @runephilosof-karnovgroup, @allanger, @xoxys, @gapanyc, @mikhail-putilov, @kaylynb, @voidcontext, @robbie-cahill, @micash545, @dominic-p, @mzampetakis ### ✨ Features - Add pull request closed event [[#2684](https://github.com/woodpecker-ci/woodpecker/pull/2684)] - Add depends_on support for steps [[#2771](https://github.com/woodpecker-ci/woodpecker/pull/2771)] - gitlab: support nested repos [[#2981](https://github.com/woodpecker-ci/woodpecker/pull/2981)] - Support go plugins for forges and agent backends [[#2751](https://github.com/woodpecker-ci/woodpecker/pull/2751)] ### 📈 Enhancement - Show default branch on top [[#3019](https://github.com/woodpecker-ci/woodpecker/pull/3019)] - Support more addon types [[#2984](https://github.com/woodpecker-ci/woodpecker/pull/2984)] - Hide PR tab if PRs are disabled [[#3004](https://github.com/woodpecker-ci/woodpecker/pull/3004)] - Switch to ULID [[#2986](https://github.com/woodpecker-ci/woodpecker/pull/2986)] - Ignore pipelines without config [[#2949](https://github.com/woodpecker-ci/woodpecker/pull/2949)] - Link labels to input and select [[#2974](https://github.com/woodpecker-ci/woodpecker/pull/2974)] - Register Agent with hostname [[#2936](https://github.com/woodpecker-ci/woodpecker/pull/2936)] - Update slogan & logo [[#2962](https://github.com/woodpecker-ci/woodpecker/pull/2962)] - Improve error handling when activating a repository [[#2965](https://github.com/woodpecker-ci/woodpecker/pull/2965)] - Add check for storage where repo/org name is empty [[#2968](https://github.com/woodpecker-ci/woodpecker/pull/2968)] - Update pipeline icons [[#2783](https://github.com/woodpecker-ci/woodpecker/pull/2783)] - Kubernetes refactor [[#2794](https://github.com/woodpecker-ci/woodpecker/pull/2794)] - Export changed files via builtin environment variables [[#2935](https://github.com/woodpecker-ci/woodpecker/pull/2935)] - Show secrets from org and global level [[#2873](https://github.com/woodpecker-ci/woodpecker/pull/2873)] - Only update pipelineStatus in one place [[#2952](https://github.com/woodpecker-ci/woodpecker/pull/2952)] - Rename `engine` to `backend` [[#2950](https://github.com/woodpecker-ci/woodpecker/pull/2950)] - Add linting for `log.Fatal()` [[#2946](https://github.com/woodpecker-ci/woodpecker/pull/2946)] - Remove separate root path config [[#2943](https://github.com/woodpecker-ci/woodpecker/pull/2943)] - init CI_COMMIT_TAG if commit ref is a tag [[#2934](https://github.com/woodpecker-ci/woodpecker/pull/2934)] - Update go module path for major version 2 [[#2905](https://github.com/woodpecker-ci/woodpecker/pull/2905)] - Unify date/time dependencies [[#2891](https://github.com/woodpecker-ci/woodpecker/pull/2891)] - Add linting for `any` [[#2893](https://github.com/woodpecker-ci/woodpecker/pull/2893)] - Fix vite deprecations [[#2885](https://github.com/woodpecker-ci/woodpecker/pull/2885)] - Migrate to Xormigrate [[#2711](https://github.com/woodpecker-ci/woodpecker/pull/2711)] - Simple security context options (Kubernetes) [[#2550](https://github.com/woodpecker-ci/woodpecker/pull/2550)] - Changes PullRequest Index to ForgeRemoteID type [[#2823](https://github.com/woodpecker-ci/woodpecker/pull/2823)] ### 🐛 Bug Fixes - Hide queue visualization if nothing to show [[#3003](https://github.com/woodpecker-ci/woodpecker/pull/3003)] - fix and lint swagger file [[#3007](https://github.com/woodpecker-ci/woodpecker/pull/3007)] - Fix IPv6 host aliases for kubernetes [[#2992](https://github.com/woodpecker-ci/woodpecker/pull/2992)] - Fix cli lint throwing error on warnings [[#2995](https://github.com/woodpecker-ci/woodpecker/pull/2995)] - Fix static file caching [[#2975](https://github.com/woodpecker-ci/woodpecker/pull/2975)] - Gitea driver: ignore GetOrg error if we get a valid user. [[#2967](https://github.com/woodpecker-ci/woodpecker/pull/2967)] - feat(k8s): Add a port name to service definition [[#2933](https://github.com/woodpecker-ci/woodpecker/pull/2933)] - Fix error container overflow [[#2957](https://github.com/woodpecker-ci/woodpecker/pull/2957)] - ignore some errors on repairAllRepos [[#2792](https://github.com/woodpecker-ci/woodpecker/pull/2792)] - Allow to restart pipelines that has warnings [[#2939](https://github.com/woodpecker-ci/woodpecker/pull/2939)] - Fix skipped pipelines model [[#2923](https://github.com/woodpecker-ci/woodpecker/pull/2923)] - fix: Add `backend_options` to service linter entry [[#2930](https://github.com/woodpecker-ci/woodpecker/pull/2930)] - Fix flags added multiple times [[#2914](https://github.com/woodpecker-ci/woodpecker/pull/2914)] - Fix schema validation with array syntax for clone and services [[#2920](https://github.com/woodpecker-ci/woodpecker/pull/2920)] - Fix prometheus docs [[#2919](https://github.com/woodpecker-ci/woodpecker/pull/2919)] - Fix podman agent container in v2 [[#2897](https://github.com/woodpecker-ci/woodpecker/pull/2897)] - Fix bitbucket org fetching [[#2874](https://github.com/woodpecker-ci/woodpecker/pull/2874)] - Only deploy docs on `main` [[#2892](https://github.com/woodpecker-ci/woodpecker/pull/2892)] - Fix pipeline-related environment [[#2876](https://github.com/woodpecker-ci/woodpecker/pull/2876)] - Fix version check partially [[#2871](https://github.com/woodpecker-ci/woodpecker/pull/2871)] - Fix unregistering agents when using agent tokens [[#2870](https://github.com/woodpecker-ci/woodpecker/pull/2870)] ### 📚 Documentation - [Awesome Woodpecker] added yet another autoscaler [[#3011](https://github.com/woodpecker-ci/woodpecker/pull/3011)] - Add cookbook blog and improve docs [[#3002](https://github.com/woodpecker-ci/woodpecker/pull/3002)] - Replace multi-pipelines with workflows on docs frontpage [[#2990](https://github.com/woodpecker-ci/woodpecker/pull/2990)] - Update README badges [[#2956](https://github.com/woodpecker-ci/woodpecker/pull/2956)] - Update 20-kubernetes.md [[#2927](https://github.com/woodpecker-ci/woodpecker/pull/2927)] - Add release documentation to CONTRIBUTING [[#2917](https://github.com/woodpecker-ci/woodpecker/pull/2917)] - Add nix-attic plugin to the index [[#2889](https://github.com/woodpecker-ci/woodpecker/pull/2889)] - Add usage with Tunnelmole to docs [[#2881](https://github.com/woodpecker-ci/woodpecker/pull/2881)] - Improve code blocks in docs [[#2879](https://github.com/woodpecker-ci/woodpecker/pull/2879)] - Add a blog post [[#2877](https://github.com/woodpecker-ci/woodpecker/pull/2877)] - Add documentation on Kubernetes securityContext [[#2822](https://github.com/woodpecker-ci/woodpecker/pull/2822)] - Add default page to categories [[#2869](https://github.com/woodpecker-ci/woodpecker/pull/2869)] - Use same format for Github docs as used for the other forges [[#2866](https://github.com/woodpecker-ci/woodpecker/pull/2866)] ### Misc - chore(deps): update dependency isomorphic-dompurify to v2 [[#3001](https://github.com/woodpecker-ci/woodpecker/pull/3001)] - fix(deps): update dependency @intlify/unplugin-vue-i18n to v2 [[#2998](https://github.com/woodpecker-ci/woodpecker/pull/2998)] - Fix go in gitpod [[#2973](https://github.com/woodpecker-ci/woodpecker/pull/2973)] - fix(deps): update module google.golang.org/grpc to v1.60.1 [[#2969](https://github.com/woodpecker-ci/woodpecker/pull/2969)] - chore(deps): update docker.io/alpine docker tag to v3.19 [[#2970](https://github.com/woodpecker-ci/woodpecker/pull/2970)] - Fix broken gated repos [[#2959](https://github.com/woodpecker-ci/woodpecker/pull/2959)] - fix(deps): update golang (packages) [[#2958](https://github.com/woodpecker-ci/woodpecker/pull/2958)] - Update docker.io/techknowlogick/xgo Docker tag to go-1.21.5 [[#2926](https://github.com/woodpecker-ci/woodpecker/pull/2926)] - Update docker.io/golang Docker tag to v1.21.5 [[#2925](https://github.com/woodpecker-ci/woodpecker/pull/2925)] - Lock file maintenance [[#2910](https://github.com/woodpecker-ci/woodpecker/pull/2910)] - Update web npm deps non-major [[#2909](https://github.com/woodpecker-ci/woodpecker/pull/2909)] - Update docs npm deps non-major [[#2908](https://github.com/woodpecker-ci/woodpecker/pull/2908)] - Update golang (packages) [[#2904](https://github.com/woodpecker-ci/woodpecker/pull/2904)] - Update module github.com/google/go-github/v56 to v57 [[#2899](https://github.com/woodpecker-ci/woodpecker/pull/2899)] - Update dependency marked to v11 [[#2898](https://github.com/woodpecker-ci/woodpecker/pull/2898)] - Update dependency vite-svg-loader to v5 [[#2837](https://github.com/woodpecker-ci/woodpecker/pull/2837)] - Update golang (packages) [[#2894](https://github.com/woodpecker-ci/woodpecker/pull/2894)] - Update web npm deps non-major [[#2895](https://github.com/woodpecker-ci/woodpecker/pull/2895)] - Update web npm deps non-major [[#2884](https://github.com/woodpecker-ci/woodpecker/pull/2884)] - Update docker.io/woodpeckerci/plugin-docker-buildx Docker tag to v2.2.1 [[#2883](https://github.com/woodpecker-ci/woodpecker/pull/2883)] ## [2.0.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v2.0.0) - 2023-11-23 ### ❤️ Thanks to all contributors! ❤️ @qwerty287, @anbraten, @6543, @renovate[bot], @pat-s, @zc-devs, @xoxys, @lafriks, @silverwind, @pre-commit-ci[bot], @riczescaran, @J-Ha, @Janik-Haag, @jbiblio, @runephilosof-karnovgroup, @bitethecode, @HamburgerJungeJr, @nitram509, @JohnWalkerx, @OskarsPakers, @Exar04, @dominic-p, @categulario, @mzampetakis, @Timshel, @Denperidge, @tomix1024, @lonix1, @s3lph, @math3vz, @LTek-online, @testwill, @klinux, @pinpox, @hpidcock, @ChewingBever, @azdle, @praneeth-ovckd ### 💥 Breaking changes - Rename `link` to `url` [[#2812](https://github.com/woodpecker-ci/woodpecker/pull/2812)] - Revert to singular CLI args [[#2820](https://github.com/woodpecker-ci/woodpecker/pull/2820)] - Use int64 for IDs in woodpecker client lib [[#2703](https://github.com/woodpecker-ci/woodpecker/pull/2703)] - Woodpecker-go: Use Feed instead of Activity [[#2690](https://github.com/woodpecker-ci/woodpecker/pull/2690)] - Do not sanitzie secrets with 3 or less chars [[#2680](https://github.com/woodpecker-ci/woodpecker/pull/2680)] - fix(deps): update docker to v24 [[#2675](https://github.com/woodpecker-ci/woodpecker/pull/2675)] - Remove `WOODPECKER_DOCS` config [[#2647](https://github.com/woodpecker-ci/woodpecker/pull/2647)] - Remove plugin-only option from secrets [[#2213](https://github.com/woodpecker-ci/woodpecker/pull/2213)] - Remove deprecated API paths [[#2639](https://github.com/woodpecker-ci/woodpecker/pull/2639)] - Remove SSH backend [[#2635](https://github.com/woodpecker-ci/woodpecker/pull/2635)] - Remove deprecated `build` command [[#2602](https://github.com/woodpecker-ci/woodpecker/pull/2602)] - Deprecate "platform" filter in favour of "labels" [[#2181](https://github.com/woodpecker-ci/woodpecker/pull/2181)] - Remove useless "sync" option from RepoListOpts from the client lib [[#2090](https://github.com/woodpecker-ci/woodpecker/pull/2090)] - Drop deprecated built-in environment variables [[#2048](https://github.com/woodpecker-ci/woodpecker/pull/2048)] ### 🔒 Security - Never log tokens [[#2466](https://github.com/woodpecker-ci/woodpecker/pull/2466)] - Check permissions on repo lookup [[#2357](https://github.com/woodpecker-ci/woodpecker/pull/2357)] - Change token logging to trace level [[#2247](https://github.com/woodpecker-ci/woodpecker/pull/2247)] - Validate webhook before changing any data [[#2221](https://github.com/woodpecker-ci/woodpecker/pull/2221)] ### ✨ Features - Add version and update notes [[#2722](https://github.com/woodpecker-ci/woodpecker/pull/2722)] - Add repos list for admins [[#2347](https://github.com/woodpecker-ci/woodpecker/pull/2347)] - Add org list [[#2338](https://github.com/woodpecker-ci/woodpecker/pull/2338)] - Add option to configure tolerations in kubernetes backend [[#2249](https://github.com/woodpecker-ci/woodpecker/pull/2249)] - Support user secrets [[#2126](https://github.com/woodpecker-ci/woodpecker/pull/2126)] - Add opt save global log output to file [[#2115](https://github.com/woodpecker-ci/woodpecker/pull/2115)] - Support bitbucket Dir() and support multi-workflows [[#2045](https://github.com/woodpecker-ci/woodpecker/pull/2045)] - Add ping command to server to allow container healthchecks [[#2030](https://github.com/woodpecker-ci/woodpecker/pull/2030)] ### 📚 Documentation - Add 2.0.0 post [[#2864](https://github.com/woodpecker-ci/woodpecker/pull/2864)] - Add extend env plugin [[#2847](https://github.com/woodpecker-ci/woodpecker/pull/2847)] - mark v1.0.x as unmaintained [[#2818](https://github.com/woodpecker-ci/woodpecker/pull/2818)] - Update docs npm deps non-major [[#2799](https://github.com/woodpecker-ci/woodpecker/pull/2799)] - Add docs about Gitea on same host and update docker-compose example [[#2752](https://github.com/woodpecker-ci/woodpecker/pull/2752)] - Update docusaurus plugin [[#2804](https://github.com/woodpecker-ci/woodpecker/pull/2804)] - Mark kubernetes backend as fully supported [[#2756](https://github.com/woodpecker-ci/woodpecker/pull/2756)] - Update docusaurus to v3 [[#2732](https://github.com/woodpecker-ci/woodpecker/pull/2732)] - Fix the wrong link to the cron job document [[#2740](https://github.com/woodpecker-ci/woodpecker/pull/2740)] - Improve secrets documentation [[#2707](https://github.com/woodpecker-ci/woodpecker/pull/2707)] - Add woodpecker-lint tool [[#2648](https://github.com/woodpecker-ci/woodpecker/pull/2648)] - Add autoscaler docs [[#2631](https://github.com/woodpecker-ci/woodpecker/pull/2631)] - Rework setup docs [[#2630](https://github.com/woodpecker-ci/woodpecker/pull/2630)] - doc: improve prometheus docs [[#2617](https://github.com/woodpecker-ci/woodpecker/pull/2617)] - docs add nixos install instructions [[#2616](https://github.com/woodpecker-ci/woodpecker/pull/2616)] - Add prettier plugin [[#2621](https://github.com/woodpecker-ci/woodpecker/pull/2621)] - [doc] improve documentation WOODPECKER_SESSION_EXPIRES [[#2603](https://github.com/woodpecker-ci/woodpecker/pull/2603)] - Update documentation WRT to recent `$platform` changes [[#2531](https://github.com/woodpecker-ci/woodpecker/pull/2531)] - Add plugin "GitHub release" [[#2592](https://github.com/woodpecker-ci/woodpecker/pull/2592)] - Cleanup docs [[#2478](https://github.com/woodpecker-ci/woodpecker/pull/2478)] - Add plugin "Release helper" [[#2584](https://github.com/woodpecker-ci/woodpecker/pull/2584)] - Add plugin "Gitea Create Pull Request" to plugin index [[#2581](https://github.com/woodpecker-ci/woodpecker/pull/2581)] - Adjust github scopes and clarify documentation. [[#2578](https://github.com/woodpecker-ci/woodpecker/pull/2578)] - Remove redundant definition of webhook form docs [[#2561](https://github.com/woodpecker-ci/woodpecker/pull/2561)] - Add notes about CRI-O specific config [[#2546](https://github.com/woodpecker-ci/woodpecker/pull/2546)] - Fix incorrect yaml syntax for `ref` in docs [[#2518](https://github.com/woodpecker-ci/woodpecker/pull/2518)] - Local image documentation [[#2521](https://github.com/woodpecker-ci/woodpecker/pull/2521)] - Adds bitbucket tag support in docs [[#2536](https://github.com/woodpecker-ci/woodpecker/pull/2536)] - Fix docs duplicate WOODPECKER_HOST assignment [[#2501](https://github.com/woodpecker-ci/woodpecker/pull/2501)] - Update github auth install [[#2499](https://github.com/woodpecker-ci/woodpecker/pull/2499)] - Update GH app installation instructions [[#2472](https://github.com/woodpecker-ci/woodpecker/pull/2472)] - Add videos [[#2465](https://github.com/woodpecker-ci/woodpecker/pull/2465)] - docs: missing info for runs_on [[#2457](https://github.com/woodpecker-ci/woodpecker/pull/2457)] - Add hint about alternative pipeline skip syntax [[#2443](https://github.com/woodpecker-ci/woodpecker/pull/2443)] - Fix typo in GitLab docs [[#2376](https://github.com/woodpecker-ci/woodpecker/pull/2376)] - clarify setup with gitlab with RFC1918 nets and non standard TLDs [[#2363](https://github.com/woodpecker-ci/woodpecker/pull/2363)] - Clarify env var `CI` in docs [[#2349](https://github.com/woodpecker-ci/woodpecker/pull/2349)] - docs: yaml cheatsheet for advanced syntax [[#2329](https://github.com/woodpecker-ci/woodpecker/pull/2329)] - Improve explanation for globs in when:path [[#2252](https://github.com/woodpecker-ci/woodpecker/pull/2252)] - Fix usage description for backend-http-proxy flag [[#2250](https://github.com/woodpecker-ci/woodpecker/pull/2250)] - Restructure k8s documentation [[#2193](https://github.com/woodpecker-ci/woodpecker/pull/2193)] - Update list of "projects using Woodpecker" [[#2196](https://github.com/woodpecker-ci/woodpecker/pull/2196)] - Update 92-awesome.md [[#2195](https://github.com/woodpecker-ci/woodpecker/pull/2195)] - Better blog title/desc [[#2182](https://github.com/woodpecker-ci/woodpecker/pull/2182)] - Fix version in FAQ [[#2101](https://github.com/woodpecker-ci/woodpecker/pull/2101)] - Add blog posts/tutorials [[#2095](https://github.com/woodpecker-ci/woodpecker/pull/2095)] - update version docs about versioning [[#2086](https://github.com/woodpecker-ci/woodpecker/pull/2086)] - Fix client example [[#2085](https://github.com/woodpecker-ci/woodpecker/pull/2085)] - Update docs deps to address cves [[#2080](https://github.com/woodpecker-ci/woodpecker/pull/2080)] - fix: global registry docs [[#2070](https://github.com/woodpecker-ci/woodpecker/pull/2070)] - Improve bitbucket docs [[#2066](https://github.com/woodpecker-ci/woodpecker/pull/2066)] - update docs about versioning [[#2043](https://github.com/woodpecker-ci/woodpecker/pull/2043)] - Set v1.0 documents as default and mark v0.15 as unmaintained [[#2034](https://github.com/woodpecker-ci/woodpecker/pull/2034)] ### 📈 Enhancement - Cleanup plugins index [[#2856](https://github.com/woodpecker-ci/woodpecker/pull/2856)] - Bump default clone image version to 2.4.0 [[#2852](https://github.com/woodpecker-ci/woodpecker/pull/2852)] - Signal to clients the hook and event routes where removed [[#2826](https://github.com/woodpecker-ci/woodpecker/pull/2826)] - Replace `interface{}` with `any` [[#2807](https://github.com/woodpecker-ci/woodpecker/pull/2807)] - Fix repo owner filter [[#2808](https://github.com/woodpecker-ci/woodpecker/pull/2808)] - Sort agents list by ID [[#2795](https://github.com/woodpecker-ci/woodpecker/pull/2795)] - Fix css loading order in head [[#2785](https://github.com/woodpecker-ci/woodpecker/pull/2785)] - Fix error color contrast in dark theme [[#2778](https://github.com/woodpecker-ci/woodpecker/pull/2778)] - Replace linter icons to match theme [[#2765](https://github.com/woodpecker-ci/woodpecker/pull/2765)] - Switch to go vanity urls [[#2706](https://github.com/woodpecker-ci/woodpecker/pull/2706)] - Add workflow version [[#2476](https://github.com/woodpecker-ci/woodpecker/pull/2476)] - UI enhancements/fixes [[#2754](https://github.com/woodpecker-ci/woodpecker/pull/2754)] - Fail on missing secrets [[#2749](https://github.com/woodpecker-ci/woodpecker/pull/2749)] - Add deprecation warnings [[#2725](https://github.com/woodpecker-ci/woodpecker/pull/2725)] - Enhance linter and errors [[#1572](https://github.com/woodpecker-ci/woodpecker/pull/1572)] - Option to change temp dir for local backend [[#2702](https://github.com/woodpecker-ci/woodpecker/pull/2702)] - Revert breaking pipeline changes [[#2677](https://github.com/woodpecker-ci/woodpecker/pull/2677)] - Add ports into pipeline backend step model [[#2656](https://github.com/woodpecker-ci/woodpecker/pull/2656)] - Unregister stateless agents from server on termination [[#2606](https://github.com/woodpecker-ci/woodpecker/pull/2606)] - Let the backend engine report the current platform [[#2688](https://github.com/woodpecker-ci/woodpecker/pull/2688)] - Showing the pending pipelines on top [[#1488](https://github.com/woodpecker-ci/woodpecker/pull/1488)] - Print local backend command logs [[#2678](https://github.com/woodpecker-ci/woodpecker/pull/2678)] - Report problems with listening to ports and exit [[#2102](https://github.com/woodpecker-ci/woodpecker/pull/2102)] - Use path.Join for server side path generation [[#2689](https://github.com/woodpecker-ci/woodpecker/pull/2689)] - Refactor UI dark/bright mode [[#2590](https://github.com/woodpecker-ci/woodpecker/pull/2590)] - Stop steps after they are done [[#2681](https://github.com/woodpecker-ci/woodpecker/pull/2681)] - Fix where syntax [[#2676](https://github.com/woodpecker-ci/woodpecker/pull/2676)] - Add "Repair all" button [[#2642](https://github.com/woodpecker-ci/woodpecker/pull/2642)] - Use pagination utils [[#2633](https://github.com/woodpecker-ci/woodpecker/pull/2633)] - Dynamic forge request size [[#2622](https://github.com/woodpecker-ci/woodpecker/pull/2622)] - Update to docker 23 [[#2577](https://github.com/woodpecker-ci/woodpecker/pull/2577)] - Refactor/simplify pubsub [[#2554](https://github.com/woodpecker-ci/woodpecker/pull/2554)] - Refactor pipeline parsing and forge refreshing [[#2527](https://github.com/woodpecker-ci/woodpecker/pull/2527)] - Fix gitlab hooks and simplify config extension [[#2537](https://github.com/woodpecker-ci/woodpecker/pull/2537)] - Set home variable in local backend for windows [[#2323](https://github.com/woodpecker-ci/woodpecker/pull/2323)] - Some cleanups about host config [[#2490](https://github.com/woodpecker-ci/woodpecker/pull/2490)] - Fix usage of WOODPECKER_ROOT_PATH [[#2485](https://github.com/woodpecker-ci/woodpecker/pull/2485)] - Some UI enhancement [[#2468](https://github.com/woodpecker-ci/woodpecker/pull/2468)] - Harmonize pipeline status information and add a review link to the approval [[#2345](https://github.com/woodpecker-ci/woodpecker/pull/2345)] - Add Renovate [[#2360](https://github.com/woodpecker-ci/woodpecker/pull/2360)] - Add option to render button as link [[#2378](https://github.com/woodpecker-ci/woodpecker/pull/2378)] - Close sidebar on outside clicks [[#2325](https://github.com/woodpecker-ci/woodpecker/pull/2325)] - Add release helper [[#1976](https://github.com/woodpecker-ci/woodpecker/pull/1976)] - Use API error helpers and improve response codes [[#2366](https://github.com/woodpecker-ci/woodpecker/pull/2366)] - Import packages only once [[#2362](https://github.com/woodpecker-ci/woodpecker/pull/2362)] - Execute `make generate` with new versions [[#2365](https://github.com/woodpecker-ci/woodpecker/pull/2365)] - Only show commit title [[#2361](https://github.com/woodpecker-ci/woodpecker/pull/2361)] - Truncate commit message in pipeline log view header [[#2356](https://github.com/woodpecker-ci/woodpecker/pull/2356)] - Increase header padding again [[#2348](https://github.com/woodpecker-ci/woodpecker/pull/2348)] - Use full width header on pipeline view and show repo name [[#2327](https://github.com/woodpecker-ci/woodpecker/pull/2327)] - Use html list for changed files list [[#2346](https://github.com/woodpecker-ci/woodpecker/pull/2346)] - Show that repo is disabled [[#2340](https://github.com/woodpecker-ci/woodpecker/pull/2340)] - Add spacing to pipeline feed spinner [[#2326](https://github.com/woodpecker-ci/woodpecker/pull/2326)] - Autodetect host platform in Makefile [[#2322](https://github.com/woodpecker-ci/woodpecker/pull/2322)] - Add "plugin" support to local backend [[#2239](https://github.com/woodpecker-ci/woodpecker/pull/2239)] - Rename grpc pipeline to workflow [[#2173](https://github.com/woodpecker-ci/woodpecker/pull/2173)] - Pass netrc data to external config service request [[#2310](https://github.com/woodpecker-ci/woodpecker/pull/2310)] - Create settings-panel vue component and use InputFields [[#2177](https://github.com/woodpecker-ci/woodpecker/pull/2177)] - Use browser-native tooltips [[#2189](https://github.com/woodpecker-ci/woodpecker/pull/2189)] - Improve agent rpc retry logic with exponential backoff [[#2205](https://github.com/woodpecker-ci/woodpecker/pull/2205)] - Skip settings proxy config with WithProxy if its empty [[#2242](https://github.com/woodpecker-ci/woodpecker/pull/2242)] - Move hook and events-stream routes to use `/api` prefix [[#2212](https://github.com/woodpecker-ci/woodpecker/pull/2212)] - Add SSH clone URL env var [[#2198](https://github.com/woodpecker-ci/woodpecker/pull/2198)] - Small improvements to mobile interface [[#2202](https://github.com/woodpecker-ci/woodpecker/pull/2202)] - Switch to upstream ttlcache [[#2187](https://github.com/woodpecker-ci/woodpecker/pull/2187)] - Convert EqualStringSlice to generic EqualSliceValues [[#2179](https://github.com/woodpecker-ci/woodpecker/pull/2179)] - Pass netrc to trusted clone images [[#2163](https://github.com/woodpecker-ci/woodpecker/pull/2163)] - Use Vue setup directive [[#2165](https://github.com/woodpecker-ci/woodpecker/pull/2165)] - Release file lock on USR1 signal [[#2151](https://github.com/woodpecker-ci/woodpecker/pull/2151)] - Use min/max width for pipeline step list [[#2141](https://github.com/woodpecker-ci/woodpecker/pull/2141)] - Add header to pipeline log and always show buttons [[#2140](https://github.com/woodpecker-ci/woodpecker/pull/2140)] - Use fix width for pipeline step list [[#2138](https://github.com/woodpecker-ci/woodpecker/pull/2138)] - Make sure we dont have hidden options for backend and pipeline compiler [[#2123](https://github.com/woodpecker-ci/woodpecker/pull/2123)] - Enhance local backend [[#2017](https://github.com/woodpecker-ci/woodpecker/pull/2017)] - Don't show badge without information [[#2130](https://github.com/woodpecker-ci/woodpecker/pull/2130)] - CLI repo sync: Show `forge-remote-id` [[#2103](https://github.com/woodpecker-ci/woodpecker/pull/2103)] - Lazy-load TimeAgo locales [[#2094](https://github.com/woodpecker-ci/woodpecker/pull/2094)] - Improve user settings [[#2087](https://github.com/woodpecker-ci/woodpecker/pull/2087)] - Allow to disable swagger [[#2093](https://github.com/woodpecker-ci/woodpecker/pull/2093)] - Use consistent woodpecker color scheme [[#2003](https://github.com/woodpecker-ci/woodpecker/pull/2003)] - Change master to main [[#2044](https://github.com/woodpecker-ci/woodpecker/pull/2044)] - Remove default branch fallbacks [[#2065](https://github.com/woodpecker-ci/woodpecker/pull/2065)] - Remove fallback check for old sqlite file location [[#2046](https://github.com/woodpecker-ci/woodpecker/pull/2046)] - Include the function name in generic datastore errors [[#2041](https://github.com/woodpecker-ci/woodpecker/pull/2041)] ### 🐛 Bug Fixes - Fix plugin URLs [[#2850](https://github.com/woodpecker-ci/woodpecker/pull/2850)] - Fix env vars and add UI url [[#2811](https://github.com/woodpecker-ci/woodpecker/pull/2811)] - Fix paths for version check [[#2816](https://github.com/woodpecker-ci/woodpecker/pull/2816)] - Add `privileged` schema definition [[#2777](https://github.com/woodpecker-ci/woodpecker/pull/2777)] - Use unique label selector for pod label for kubernetes services [[#2723](https://github.com/woodpecker-ci/woodpecker/pull/2723)] - Some UI fixes [[#2698](https://github.com/woodpecker-ci/woodpecker/pull/2698)] - Fix active tab not updating on prop change [[#2712](https://github.com/woodpecker-ci/woodpecker/pull/2712)] - Unique status for matrix [[#2695](https://github.com/woodpecker-ci/woodpecker/pull/2695)] - Fix secret image filter regex [[#2674](https://github.com/woodpecker-ci/woodpecker/pull/2674)] - local backend ignore errors in commands in between [[#2636](https://github.com/woodpecker-ci/woodpecker/pull/2636)] - Do not print log level on CLI [[#2638](https://github.com/woodpecker-ci/woodpecker/pull/2638)] - Fix error when closing logs [[#2637](https://github.com/woodpecker-ci/woodpecker/pull/2637)] - Fix `CI_WORKSPACE` in local backend [[#2627](https://github.com/woodpecker-ci/woodpecker/pull/2627)] - Some mobile UI fixes [[#2624](https://github.com/woodpecker-ci/woodpecker/pull/2624)] - Fix secret priority [[#2599](https://github.com/woodpecker-ci/woodpecker/pull/2599)] - UI cleanups and improvements [[#2548](https://github.com/woodpecker-ci/woodpecker/pull/2548)] - Fix PR event trigger and list for bitbucket repos [[#2539](https://github.com/woodpecker-ci/woodpecker/pull/2539)] - Fix ccmenu endpoint [[#2543](https://github.com/woodpecker-ci/woodpecker/pull/2543)] - Trim last "/" from WOODPECKER_HOST config [[#2538](https://github.com/woodpecker-ci/woodpecker/pull/2538)] - Use correct mime type when no content is sent [[#2515](https://github.com/woodpecker-ci/woodpecker/pull/2515)] - Fix bitbucket branches pagination. [[#2509](https://github.com/woodpecker-ci/woodpecker/pull/2509)] - fix: change config.config_data column type to longblob in mysql [[#2434](https://github.com/woodpecker-ci/woodpecker/pull/2434)] - Fix: change tasks.task_data column type to longblob in mysql [[#2418](https://github.com/woodpecker-ci/woodpecker/pull/2418)] - Do not list archived repos for all forges [[#2374](https://github.com/woodpecker-ci/woodpecker/pull/2374)] - fix(server/api/repo): Fix repair webhook host [[#2372](https://github.com/woodpecker-ci/woodpecker/pull/2372)] - Delete repos/secrets on org deletion [[#2367](https://github.com/woodpecker-ci/woodpecker/pull/2367)] - Fix org fetching [[#2343](https://github.com/woodpecker-ci/woodpecker/pull/2343)] - Show correct event in pipeline step list [[#2334](https://github.com/woodpecker-ci/woodpecker/pull/2334)] - Add min height to mobile pipeline view and fix overflow [[#2335](https://github.com/woodpecker-ci/woodpecker/pull/2335)] - Fix grid column size in pipeline log view [[#2336](https://github.com/woodpecker-ci/woodpecker/pull/2336)] - Fix mobile login view [[#2332](https://github.com/woodpecker-ci/woodpecker/pull/2332)] - Fix button loading spinner when activating repos [[#2333](https://github.com/woodpecker-ci/woodpecker/pull/2333)] - make WOODPECKER_MIGRATIONS_ALLOW_LONG have an actuall effect [[#2251](https://github.com/woodpecker-ci/woodpecker/pull/2251)] - Docker build dont ignore ci env vars [[#2238](https://github.com/woodpecker-ci/woodpecker/pull/2238)] - Handle parsed hooks that should be ignored [[#2243](https://github.com/woodpecker-ci/woodpecker/pull/2243)] - Set correct version for release branch releases [[#2227](https://github.com/woodpecker-ci/woodpecker/pull/2227)] - Bump default git clone plugin [[#2215](https://github.com/woodpecker-ci/woodpecker/pull/2215)] - Show all steps [[#2190](https://github.com/woodpecker-ci/woodpecker/pull/2190)] - Fix pipeline config collapsing [[#2166](https://github.com/woodpecker-ci/woodpecker/pull/2166)] - Fix 'add-orgs' migration [[#2117](https://github.com/woodpecker-ci/woodpecker/pull/2117)] - docs: Environment Variable Seems to be `DOCKER_HOST`, not `DOCKER_SOCK` [[#2122](https://github.com/woodpecker-ci/woodpecker/pull/2122)] - Fix swagger response code [[#2119](https://github.com/woodpecker-ci/woodpecker/pull/2119)] - Forge Github Org: Use `login` instead of `name` [[#2104](https://github.com/woodpecker-ci/woodpecker/pull/2104)] - client.go: Fix RepoPost path [[#2091](https://github.com/woodpecker-ci/woodpecker/pull/2091)] - Fix alt text contrast in code boxes [[#2089](https://github.com/woodpecker-ci/woodpecker/pull/2089)] - Fix WOODPECKER_GRPC_VERIFY being ignored [[#2077](https://github.com/woodpecker-ci/woodpecker/pull/2077)] - Handle case where there is no latest pipeline for GetBadge [[#2042](https://github.com/woodpecker-ci/woodpecker/pull/2042)] ### Misc - Update release-helper [[#2863](https://github.com/woodpecker-ci/woodpecker/pull/2863)] - Add repo owner test [[#2857](https://github.com/woodpecker-ci/woodpecker/pull/2857)] - Update woodpeckerci/plugin-ready-release-go Docker tag to v1.0.2 [[#2853](https://github.com/woodpecker-ci/woodpecker/pull/2853)] - Update golang (packages) [[#2839](https://github.com/woodpecker-ci/woodpecker/pull/2839)] - Update dependency vite to v5 [[#2836](https://github.com/woodpecker-ci/woodpecker/pull/2836)] - Lock file maintenance [[#2840](https://github.com/woodpecker-ci/woodpecker/pull/2840)] - Update postgres Docker tag to v16.1 [[#2842](https://github.com/woodpecker-ci/woodpecker/pull/2842)] - Update docker.io/golang Docker tag to v1.21.4 [[#2828](https://github.com/woodpecker-ci/woodpecker/pull/2828)] - Update docker.io/techknowlogick/xgo Docker tag to go-1.21.4 [[#2829](https://github.com/woodpecker-ci/woodpecker/pull/2829)] - Update golang (packages) [[#2815](https://github.com/woodpecker-ci/woodpecker/pull/2815)] - Update dependency marked to v10 [[#2810](https://github.com/woodpecker-ci/woodpecker/pull/2810)] - Update release-helper [[#2801](https://github.com/woodpecker-ci/woodpecker/pull/2801)] - Remove go versions from .golangci.yml [[#2775](https://github.com/woodpecker-ci/woodpecker/pull/2775)] - [pre-commit.ci] pre-commit autoupdate [[#2767](https://github.com/woodpecker-ci/woodpecker/pull/2767)] - Lock file maintenance [[#2755](https://github.com/woodpecker-ci/woodpecker/pull/2755)] - Update golang (packages) [[#2742](https://github.com/woodpecker-ci/woodpecker/pull/2742)] - Update woodpeckerci/plugin-ready-release-go Docker tag to v0.7.0 [[#2728](https://github.com/woodpecker-ci/woodpecker/pull/2728)] - Add grafana dashobard to awesome [[#2710](https://github.com/woodpecker-ci/woodpecker/pull/2710)] - Pin alpine versions in Dockerfile [[#2649](https://github.com/woodpecker-ci/woodpecker/pull/2649)] - Use full qualifyer for images [[#2692](https://github.com/woodpecker-ci/woodpecker/pull/2692)] - chore(deps): lock file maintenance [[#2673](https://github.com/woodpecker-ci/woodpecker/pull/2673)] - fix(deps): update golang (packages) [[#2671](https://github.com/woodpecker-ci/woodpecker/pull/2671)] - Use `pre-commit` [[#2650](https://github.com/woodpecker-ci/woodpecker/pull/2650)] - fix(deps): update dependency fuse.js to v7 [[#2666](https://github.com/woodpecker-ci/woodpecker/pull/2666)] - chore(deps): update dependency @types/node to v20 [[#2664](https://github.com/woodpecker-ci/woodpecker/pull/2664)] - chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v2.2.0 [[#2663](https://github.com/woodpecker-ci/woodpecker/pull/2663)] - chore(deps): update mysql docker tag to v8.2.0 [[#2662](https://github.com/woodpecker-ci/woodpecker/pull/2662)] - Add some tests [[#2652](https://github.com/woodpecker-ci/woodpecker/pull/2652)] - chore(deps): update docs npm deps non-major [[#2660](https://github.com/woodpecker-ci/woodpecker/pull/2660)] - chore(deps): update web npm deps non-major [[#2661](https://github.com/woodpecker-ci/woodpecker/pull/2661)] - Fix codecov plugin version [[#2643](https://github.com/woodpecker-ci/woodpecker/pull/2643)] - Add prettier [[#2600](https://github.com/woodpecker-ci/woodpecker/pull/2600)] - Do not run docker prepare steps [[#2626](https://github.com/woodpecker-ci/woodpecker/pull/2626)] - Fix docker workflow and only run if needed [[#2625](https://github.com/woodpecker-ci/woodpecker/pull/2625)] - fix(deps): update golang (packages) [[#2614](https://github.com/woodpecker-ci/woodpecker/pull/2614)] - chore(deps): lock file maintenance [[#2620](https://github.com/woodpecker-ci/woodpecker/pull/2620)] - chore(deps): update codeberg.org/woodpecker-plugins/trivy docker tag to v1.0.1 [[#2618](https://github.com/woodpecker-ci/woodpecker/pull/2618)] - chore(deps): update node.js to v21 [[#2615](https://github.com/woodpecker-ci/woodpecker/pull/2615)] - Only publish PR images when label is set [[#2608](https://github.com/woodpecker-ci/woodpecker/pull/2608)] - chore(deps): lock file maintenance [[#2595](https://github.com/woodpecker-ci/woodpecker/pull/2595)] - chore(deps): update postgres docker tag to v16 [[#2588](https://github.com/woodpecker-ci/woodpecker/pull/2588)] - Update renovate schedule & use central config repo [[#2597](https://github.com/woodpecker-ci/woodpecker/pull/2597)] - chore(deps): update woodpeckerci/plugin-surge-preview docker tag to v1.2.2 [[#2593](https://github.com/woodpecker-ci/woodpecker/pull/2593)] - Update README badge link [[#2596](https://github.com/woodpecker-ci/woodpecker/pull/2596)] - fix(deps): update golang (packages) to v23.0.7+incompatible [[#2586](https://github.com/woodpecker-ci/woodpecker/pull/2586)] - Fix missing web dist [[#2580](https://github.com/woodpecker-ci/woodpecker/pull/2580)] - Run tests on `main` branch [[#2576](https://github.com/woodpecker-ci/woodpecker/pull/2576)] - fix(deps): update module github.com/google/go-github/v55 to v56 [[#2573](https://github.com/woodpecker-ci/woodpecker/pull/2573)] - Add plugin "NixOS Remote Builder" to plugin index [[#2571](https://github.com/woodpecker-ci/woodpecker/pull/2571)] - Fix renovate [[#2569](https://github.com/woodpecker-ci/woodpecker/pull/2569)] - renovate: add `golang` group [[#2567](https://github.com/woodpecker-ci/woodpecker/pull/2567)] - chore(deps): update golang docker tag to v1.21.3 [[#2564](https://github.com/woodpecker-ci/woodpecker/pull/2564)] - chore(deps): update techknowlogick/xgo docker tag to go-1.21.3 [[#2565](https://github.com/woodpecker-ci/woodpecker/pull/2565)] - fix(deps): update golang deps non-major [[#2566](https://github.com/woodpecker-ci/woodpecker/pull/2566)] - chore(deps): update mstruebing/editorconfig-checker docker tag to v2.7.2 [[#2563](https://github.com/woodpecker-ci/woodpecker/pull/2563)] - Bump to mysql 8 [[#2559](https://github.com/woodpecker-ci/woodpecker/pull/2559)] - fix(deps): update module github.com/xanzy/go-gitlab to v0.93.1 [[#2560](https://github.com/woodpecker-ci/woodpecker/pull/2560)] - Require Go 1.21 [[#2553](https://github.com/woodpecker-ci/woodpecker/pull/2553)] - chore(deps): update techknowlogick/xgo docker tag to go-1.21.2 [[#2523](https://github.com/woodpecker-ci/woodpecker/pull/2523)] - Update issue config [[#2353](https://github.com/woodpecker-ci/woodpecker/pull/2353)] - Add test for handling pipeline error [[#2547](https://github.com/woodpecker-ci/woodpecker/pull/2547)] - chore(deps): update golang docker tag to v1.21.2 [[#2532](https://github.com/woodpecker-ci/woodpecker/pull/2532)] - fix(deps): update golang.org/x/exp digest to 7918f67 [[#2535](https://github.com/woodpecker-ci/woodpecker/pull/2535)] - fix(deps): update golang deps non-major [[#2533](https://github.com/woodpecker-ci/woodpecker/pull/2533)] - fix(deps): update golang.org/x/exp digest to 3e424a5 [[#2530](https://github.com/woodpecker-ci/woodpecker/pull/2530)] - Use golangci-lint to lint zerolog [[#2524](https://github.com/woodpecker-ci/woodpecker/pull/2524)] - Renovate config updates [[#2519](https://github.com/woodpecker-ci/woodpecker/pull/2519)] - fix(deps): update module github.com/docker/distribution to v2.8.3+incompatible [[#2517](https://github.com/woodpecker-ci/woodpecker/pull/2517)] - fix(deps): update module github.com/melbahja/goph to v1.4.0 [[#2513](https://github.com/woodpecker-ci/woodpecker/pull/2513)] - fix(deps): update golang deps non-major [[#2500](https://github.com/woodpecker-ci/woodpecker/pull/2500)] - chore(deps): lock file maintenance [[#2497](https://github.com/woodpecker-ci/woodpecker/pull/2497)] - Fix broken link to 3rd party plugin library [[#2494](https://github.com/woodpecker-ci/woodpecker/pull/2494)] - fix(deps): update golang deps non-major [[#2486](https://github.com/woodpecker-ci/woodpecker/pull/2486)] - chore(deps): lock file maintenance [[#2469](https://github.com/woodpecker-ci/woodpecker/pull/2469)] - Add devx lable to compose file PRs [[#2467](https://github.com/woodpecker-ci/woodpecker/pull/2467)] - chore(deps): update postgres docker tag to v16 [[#2463](https://github.com/woodpecker-ci/woodpecker/pull/2463)] - Update gitea sdk [[#2464](https://github.com/woodpecker-ci/woodpecker/pull/2464)] - fix(deps): update golang deps non-major [[#2462](https://github.com/woodpecker-ci/woodpecker/pull/2462)] - fix(deps): update dependency ansi_up to v6 [[#2431](https://github.com/woodpecker-ci/woodpecker/pull/2431)] - chore(deps): update web npm deps non-major [[#2461](https://github.com/woodpecker-ci/woodpecker/pull/2461)] - fix(deps): update module github.com/tevino/abool to v2 [[#2460](https://github.com/woodpecker-ci/woodpecker/pull/2460)] - fix(deps): update module github.com/google/go-github/v39 to v55 [[#2456](https://github.com/woodpecker-ci/woodpecker/pull/2456)] - fix(deps): update module github.com/golang-jwt/jwt/v4 to v5 [[#2449](https://github.com/woodpecker-ci/woodpecker/pull/2449)] - fix(deps): update module github.com/golang-jwt/jwt/v4 to v5 [[#2447](https://github.com/woodpecker-ci/woodpecker/pull/2447)] - chore(deps): update node.js to v20 [[#2422](https://github.com/woodpecker-ci/woodpecker/pull/2422)] - Add renovate package rule to apply build label [[#2440](https://github.com/woodpecker-ci/woodpecker/pull/2440)] - fix(deps): update dependency prism-react-renderer to v2 [[#2436](https://github.com/woodpecker-ci/woodpecker/pull/2436)] - fix(deps): update dependency node-emoji to v2 [[#2435](https://github.com/woodpecker-ci/woodpecker/pull/2435)] - Add renovate package rule to apply dependencies label [[#2438](https://github.com/woodpecker-ci/woodpecker/pull/2438)] - fix(deps): update golang deps non-major [[#2437](https://github.com/woodpecker-ci/woodpecker/pull/2437)] - chore(deps): update postgres docker tag to v15 [[#2423](https://github.com/woodpecker-ci/woodpecker/pull/2423)] - fix(deps): update dependency esbuild-loader to v4 [[#2433](https://github.com/woodpecker-ci/woodpecker/pull/2433)] - fix(deps): update dependency clsx to v2 [[#2432](https://github.com/woodpecker-ci/woodpecker/pull/2432)] - fix(deps): update dependency @vueuse/core to v10 [[#2430](https://github.com/woodpecker-ci/woodpecker/pull/2430)] - fix(deps): update dependency @svgr/webpack to v8 [[#2429](https://github.com/woodpecker-ci/woodpecker/pull/2429)] - fix(deps): update dependency @kyvg/vue3-notification to v3 [[#2427](https://github.com/woodpecker-ci/woodpecker/pull/2427)] - fix(deps): update dependency @intlify/unplugin-vue-i18n to v1 [[#2426](https://github.com/woodpecker-ci/woodpecker/pull/2426)] - chore(deps): update typescript-eslint monorepo to v6 (major) [[#2425](https://github.com/woodpecker-ci/woodpecker/pull/2425)] - chore(deps): update react monorepo to v18 (major) [[#2424](https://github.com/woodpecker-ci/woodpecker/pull/2424)] - chore(deps): update dependency prettier to v3 [[#2420](https://github.com/woodpecker-ci/woodpecker/pull/2420)] - chore(deps): update dependency eslint-config-prettier to v9 [[#2415](https://github.com/woodpecker-ci/woodpecker/pull/2415)] - chore(deps): update dependency @tsconfig/docusaurus to v2 [[#2410](https://github.com/woodpecker-ci/woodpecker/pull/2410)] - chore(deps): update dependency typescript to v5 [[#2421](https://github.com/woodpecker-ci/woodpecker/pull/2421)] - chore(deps): update dependency concurrently to v8 [[#2414](https://github.com/woodpecker-ci/woodpecker/pull/2414)] - Add renovate deps groups [[#2417](https://github.com/woodpecker-ci/woodpecker/pull/2417)] - fix(deps): update module xorm.io/xorm to v1.3.3 [[#2393](https://github.com/woodpecker-ci/woodpecker/pull/2393)] - chore(deps): update dependency marked to v9 [[#2419](https://github.com/woodpecker-ci/woodpecker/pull/2419)] - chore(deps): update dependency @types/marked to v5 [[#2411](https://github.com/woodpecker-ci/woodpecker/pull/2411)] - fix(deps): update module github.com/rs/zerolog to v1.30.0 [[#2404](https://github.com/woodpecker-ci/woodpecker/pull/2404)] - fix(deps): update module github.com/jellydator/ttlcache/v3 to v3.1.0 [[#2402](https://github.com/woodpecker-ci/woodpecker/pull/2402)] - fix(deps): update module github.com/xanzy/go-gitlab to v0.91.1 [[#2405](https://github.com/woodpecker-ci/woodpecker/pull/2405)] - fix(deps): update module github.com/antonmedv/expr to v1.15.1 [[#2400](https://github.com/woodpecker-ci/woodpecker/pull/2400)] - chore(deps): update dependency axios to v1 [[#2413](https://github.com/woodpecker-ci/woodpecker/pull/2413)] - fix(deps): update module github.com/prometheus/client_golang to v1.16.0 [[#2403](https://github.com/woodpecker-ci/woodpecker/pull/2403)] - fix(deps): update module github.com/urfave/cli/v2 to v2.25.7 [[#2391](https://github.com/woodpecker-ci/woodpecker/pull/2391)] - fix(deps): update module google.golang.org/protobuf to v1.31.0 [[#2409](https://github.com/woodpecker-ci/woodpecker/pull/2409)] - fix(deps): update kubernetes packages to v0.28.1 [[#2399](https://github.com/woodpecker-ci/woodpecker/pull/2399)] - fix(deps): update module github.com/swaggo/swag to v1.16.2 [[#2390](https://github.com/woodpecker-ci/woodpecker/pull/2390)] - fix(deps): update dependency @easyops-cn/docusaurus-search-local to ^0.36.0 [[#2406](https://github.com/woodpecker-ci/woodpecker/pull/2406)] - fix(deps): update module github.com/stretchr/testify to v1.8.4 [[#2389](https://github.com/woodpecker-ci/woodpecker/pull/2389)] - fix(deps): update module github.com/caddyserver/certmagic to v0.19.2 [[#2401](https://github.com/woodpecker-ci/woodpecker/pull/2401)] - chore(deps): update postgres docker tag to v12.16 [[#2397](https://github.com/woodpecker-ci/woodpecker/pull/2397)] - fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.17 [[#2387](https://github.com/woodpecker-ci/woodpecker/pull/2387)] - fix(deps): update module github.com/google/uuid to v1.3.1 [[#2386](https://github.com/woodpecker-ci/woodpecker/pull/2386)] - chore(deps): update dependency unplugin-vue-components to ^0.25.0 [[#2395](https://github.com/woodpecker-ci/woodpecker/pull/2395)] - fix(deps): update dependency @intlify/unplugin-vue-i18n to ^0.13.0 [[#2398](https://github.com/woodpecker-ci/woodpecker/pull/2398)] - chore(deps): update dependency unplugin-icons to ^0.17.0 [[#2394](https://github.com/woodpecker-ci/woodpecker/pull/2394)] - chore(deps): update golang docker tag [[#2396](https://github.com/woodpecker-ci/woodpecker/pull/2396)] - fix(deps): update module github.com/moby/moby to v20.10.25+incompatible [[#2388](https://github.com/woodpecker-ci/woodpecker/pull/2388)] - fix(deps): update module github.com/docker/docker to v20.10.25+incompatible [[#2385](https://github.com/woodpecker-ci/woodpecker/pull/2385)] - fix(deps): update module github.com/docker/cli to v20.10.25+incompatible [[#2384](https://github.com/woodpecker-ci/woodpecker/pull/2384)] - fix(deps): update module github.com/alessio/shellescape to v1.4.2 [[#2381](https://github.com/woodpecker-ci/woodpecker/pull/2381)] - fix(deps): update golang.org/x/exp digest to 9212866 [[#2380](https://github.com/woodpecker-ci/woodpecker/pull/2380)] - Check for correct license header [[#2137](https://github.com/woodpecker-ci/woodpecker/pull/2137)] - Add TestCompilerCompile [[#2183](https://github.com/woodpecker-ci/woodpecker/pull/2183)] - Fix `docs` workflow [[#2128](https://github.com/woodpecker-ci/woodpecker/pull/2128)] - Add some tests for bitbucket forge [[#2097](https://github.com/woodpecker-ci/woodpecker/pull/2097)] - Publish releases and branch tags to quay.io too [[#2069](https://github.com/woodpecker-ci/woodpecker/pull/2069)] ## [1.0.5](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.5) - 2023-11-09 - ENHANCEMENTS - Switch to go vanity urls (#2706) (#2773) - MISC - Fix release pipeline for 1.x.x (#2774) ## [1.0.4](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.4) - 2023-11-05 - BUGFIXES - Fix secret image filter regex (#2674) (#2686) - Fix error when closing logs (#2637) (#2640) ## [1.0.3](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.3) - 2023-10-14 - SECURITY - Update dependencies (#2587) - Frontend: bump postcss to 8.4.31 (#2541) - Check permissions on repo lookup (#2358) - Change token logging to trace level (#2247) (#2248) - BUGFIXES - Fix gitlab hooks (#2537) (#2542) - Trim last "/" from WOODPECKER_HOST config (#2538) (#2540) - Fix(server/api/repo): Fix repair webhook host (#2372) (#2452) - Show correct event in pipeline step list (#2448) - Make WOODPECKER_MIGRATIONS_ALLOW_LONG have an actuall effect (#2251) (#2309) - Docker build dont ignore ci env vars (#2238) (#2246) - Handle parsed hooks that should be ignored (#2243) (#2244) - Return 204 not 500 on filtered pipeline (#2230) - Set correct version for release branch releases (#2227) (#2229) - MISC - Rebuild swagger with latest version (#2455) ## [1.0.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.2) - 2023-08-16 - SECURITY - Validate webhook before change any data (#2221) (#2222) - BUGFIXES - Bump default git clone plugin (#2215) (#2220) - Show all steps (#2190) (#2191) ## [1.0.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.1) - 2023-08-08 - SECURITY - Fix WOODPECKER_GRPC_VERIFY being ignored (#2077) (#2082) - BUGFIXES - Fix 'add-orgs' migration (#2117) (#2145) - Fix UI and backend paths with subpath (#1799) (#2133) - Fix swagger response code (#2119) (#2121) - Forge Github Org: Use `login` instead of `name` (#2104) (#2106) - Client.go: Backport fix RepoPost path (#2100) - Fix translation key (#2098) ## [1.0.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v1.0.0) - 2023-07-29 - BREAKING - Use IDs to access organizations (#1873) - Drop support for Bitbucket Server (#1994) - Rename yaml `pipeline` to `steps` (#1833) - Drop ".drone.yml" as default pipeline config (#1795) - Build-in Env Vars, use _URL for all links/URLs (#1794) - Resolve built-in variables for global when filtered too (#1790) - Drop "Gogs" support (#1752) - Access repos by their IDs (#1691) - Drop "coding" support (#1644) - Add queue details UI for admins (#1632) - Remove `command:` from steps (#1032) - Remove old `build` API routes (#1283) - Let single line command be a single command (#1009) - Drop deprecated environment vars (#920) - Drop Var-Args in steps in favor of settings (#919) - Fix branch condition on tags (#917) - Use asymmetric key to sign webhooks (#916) - Add agent tagging / filtering for pipelines (#902) - Delete old fallback for "drone.sqlite" (#791) - Migrate to certmagic (#360) - FEATURES - Implement YAML Map Merge, Overrides, and Sequence Merge Support (#1720) - Add users UI for admins (#1634) - Add agent no-schedule flag (#1567) - Change locale in user settings (#1305) - Add when evaluate filter (#1213) - Store an agents list and add agent heartbeats (#1189) - Add ability to trigger manual builds (#1156) - Add default event filter (#1140) - Add CLI support for global and organization secrets (#1113) - Allow multiple when conditions (#1087) - Add syntax highlighting for pipeline config (#1082) - Add `logs` command to CLI & update forges features docs (#1064) - Add method to check organization membership (#1037) - Global and organization secrets (#1027) - Add pipeline log output download (#1023) - Provide global environment variables for pipeline substitution (#968) - Add cron jobs (#934) - Support localized web UI (#912) - Add support to define a custom docker network and enable docker ipv6 (#893) - Add SSH backend (#861) - Add support for superseding runs (#831) - Add support for steps to be a list (instead of dict) (#826) - Add editing of secrets and registries (#823) - Allow loading sensitive flags from files (#815) - Add support for pipeline configuration service (#804) - Support all backends for CLI exec (#801) - Add support for pipeline root.when conditions (#770) - Add support to run pipelines using a local backend (#709) - Add initial version of Kubernetes backend (#552) - SECURITY - Fix ignoring server set pipeline max-timeout (#1875) - Only grant privileged to plugins (#1646) - Only inject netrc to trusted clone plugins (#1352) - Support plugin-only secrets (#1344) - Fix insecure /tmp usage in local backend (#872) - BUGFIXES - Handle case where there is no latest pipeline for GetBadge (#2042) (#2050) - Fix repo gate protection (#1969) - Make secrets with "/" in name editable / deletable (#1938) - Fix Bitbucket implement missing features (#1887) (#1889) - Fix nil pointer in repo repair (#1804) - Do not use OAuth client without token (#1803) - Correct label argument parsing in agent code (#1717) - Fully support `.yaml` (#1713) - Consistent status on delete (#1703) - Fix Bitbucket Server branches (#1698) - Set 'HOME' during local pipeline step (#1686) - Pipeline compiler: handle nil entrys in settings list (#1626) - Fix: backend auto-detection should be consistent (#1618) - Return 404 on badge endpoint for inactive repos (#1600) - Ensure the SharedInformerFactory closes eventually (#1585) - Deduplicate step docker container volumes (#1571) - Don't require secret value on secret edit (#1552) (#1553) - Rework status constraint logic for successes (#1515) - Don't panic on hook parsing (#1501) - Hide not owned repos from sidebar and repo list (#1453) - Fix cut of woodpecker animation (#1402) - Fix approval on mobile (#1320) - Unify buttons, links and improve focus styles (#1317) - Fix pipeline manual trigger on web (#1307) - Fix SCM visibility if user visibility is private (#1217) - Hide log output container if step does not have logs (#1086) - Fix to show build pipeline parse error (#1066) - Pipeline compiler should not alter specified image (#1005) - Gracefully handle non-zero exit code in local backend (#1002) - Replace run_on references with runs_on (#965) - Set default logging value of CLI to info (#871) - Support conditional branch as an array to align with documentation (#836) - Fix redirect after login (#824) - ENHANCEMENTS - Add BranchHead implementation for bitbucket forge (#2011) - Use global logger for xorm logs and add options (#1997) - Let HookParse func explicit ignore events (#1942) - Link swagger in navbar (#1984) - Add option to read grpc-secret from file (#1972) - Let pipeline-compiler export step types (#1958) - docker backend use uuid instead of name as identifier (#1967) - Kubernetes do not set Pod's Image pull policy if not explicitly set (#1914) - Fixed when:evaluate on non-standard (non-CI*) env vars (#1907) - Add pull-request implementation for bitbucket forge (#1889) - Store agent ID in config file (#1888) - Fix bitbucket forge add repo (#1887) - Added Woodpecker Host Config used for Webhooks (#1869) - Drop old columns (#1838) - Remove MSSQL specific code and cleanups (#1796) - Remove unused file system API (#1791) - Add Forge Metadata to built-in environment variables (#1789) - Redirect to new pipeline (#1761) - Add reset token button (#1755) - Add agent functions to go-sdk (#1754) - Always send a status back to forge (#1751) - Allow to configure listener port for SSL (#1735) - Identify users using their remote ID (#1732) - Let agent retry to connecting to server (#1728) - Stable sort order for DB lists (#1702) - Add backend label to agents (#1692) - Web: use i18n-t to avoid v-html directive (#1676) - Various UI improvements (#1663) - Do not store inactive repos without any resources (#1658) - Implement visual display of queue statistics (#1657) - Agent check gRPC version against server (#1653) - Initiate Pagination Implementation for API and Infinite Scroll in UI (#1651) - Add PR pipeline list (#1641) - Save agent-id for tasks and add endpoint to get agent tasks (#1631) - Return 404 if pipeline not exist and handle 404 errors in WebUI (#1627) - UI should confirm secret deletion (#1604) - Add collapsable support to panel elements (#1601) - Add cancel button on secrets tab (#1599) - Allow custom dnsConfig in agent deployment (#1569) - Show platform, backend and capacity as badges in agent list (#1568) - Define WOODPECKER_FORGE_TIMEOUT server config (#1558) - Sort repos by org/name (#1548) - Improve button and input contrast in dark mode (#1456) - Consistent and more descriptive naming of parameters in index.ts (#1455) - Add button in UI to trigger the deployment event (#1415) - Use icons for step and workflow states (#1409) - Match notification font size to rest of the UI (#1399) - Support .yaml as file-ending for workflow config too (#1388) - Show workflow state in UI and collapse completed workflows (#1383) - Use pipeline wrapper and improve scaffold UI (#1368) - Lazy load locales (#1362) - Always use rounded quadrat user avatars (#1350) - Fix display of long pipeline and job names (#1346) - Support changed files for Gitea PRs (#1342) - Allow to change directory for steps (#1329) - UI use system font stack (#1326) - Add pull request labels as environment variable (#1321) - Make pipeline workflows collapsable (#1304) - Make submit buttons green and add forms (#1302) - Add pipeline build number into Pipeline list (#1301) - Add title to docs links (#1298) - Check if repo exists before creating pipeline (#1297) - Use HTML buttons to allow keyboard navigation (#1242) - Introduce and use Pagination helper func (#1236) - Sort secret lists and events (#1223) - Add support sub-settings and secrets in sub-settings (#1221) - Add option to ignore failures on steps (#1219) - Set a default value for `pipeline-event` flag of `cli exec` command (#1212) - Add option for docker runtime to provide default volumes (#1203) - Make healthcheck port configurable (#1197) - Don't show "changed files" if event can't have them (#1191) - Add dedicated DroneCI env compatibility layer (#1185) - Only enable debug endpoints if log level is debug or below (#1160) - Sort pipelines based on creation date (#1159) - Add option to turn on and off log automatic scrolling (#1149) - Checkout tags on tag pipeline (#1110) - Use fixed version of git clone plugin (#1108) - Fetch repositories with remote ID if possible (#1078) - Support Docker credential helpers (#1075) - Do not show pipeline name if it's a single file (#1069) - Remove xterm and use ansi converter for logs (#1067) - Update jsonschema and define "services" (#1036) - Show forge icons in UI (#987) - Make pipeline runtime log with description (#970) - Improve UI colors to have more contrast (#943) - Add branches support for BitBucket (#907) - Auto cancel blocked pipelines (#905) - Allow to change forge status messages (#900) - Added support for step errors when executing backend (#817) - Do not filter on linux/amd64 per default (#805) - DOCUMENTATION - Remove never implemented "tag"-filter and document "ref"-filter to do the same (#1820) - Define Glossary (#1800) - Add more documentation about branch matching (#1186) - Use versioned docs (#1145) - Add gitpod setup (#1020) - MISC - Drop tarball release (#1819) - Move helm charts to own repo "helm" (#1589) - Replace yarn with pnpm (#1240) - Publish preview docker images of pulls (#1072) ## [0.15.11](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.11) - 2023-07-12 - SECURITY - Update github.com/gin-gonic/gin to 1.9.1 (#1989) - ENHANCEMENTS - Allow gitea dev version (#914) (#1988) ## [0.15.10](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.10) - 2023-07-09 - SECURITY - Fix agent auth (#1952) (#1953) - Return after error (#1875) (#1876) - Update github.com/docker/distribution (#1750) ## [0.15.9](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.9) - 2023-05-11 - SECURITY - Backport securitycheck and bump deps where needed (#1745) ## [0.15.8](https://github.com/woodpecker-ci/woodpecker/releases/tag/0.15.8) - 2023-04-29 - BUGFIXES - Use codeberg.org/6543/go-yaml2json (#1719) - Fix faulty hardlink in release tarball (#1669) (#1671) - Persist `DepStatus` of tasks (#1610) (#1625) ## [0.15.7](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.7) - 2023-03-14 - SECURITY - Update dependencies golang/x libs (#1612) (#1621) - BUGFIXES - Docker backend should not close 'engine.Tail' result (#1616) (#1620) - Force pure Go resolver onto server (#1502) (#1503) - ENHANCEMENTS - SanitizeParamKey "-" to "_" for plugin settings (#1511) - MISC - Bump xgo and go to v1.19.5 (#1538) (#1547) - Pin official default clone image (#1526) (#1534) ## [0.15.6](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.6) - 2022-12-23 - SECURITY - Update golang.org/x/net (#1494) - [**BREAKING**] Disable metrics access if no token is set (#1469) (#1470) - Update dep moby (#1263) (#1264) - BUGFIXES - Update json schema for cli lint to cover valid cases (#1384) - Add pipeline.step.when.branch string-array type to schema.json (#1380) - Display system CA error only if there is an error (#870) (#1286) - ENHANCEMENTS - Bump Frontend Deps and remove unused (#1404) ## [0.15.5](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.5) - 2022-10-13 - BUGFIXES - Change build message column type to text (#1252) (#1253) - ENHANCEMENTS - Bump DefaultCloneImage version to v1.6.0 (#1254) - On Repo update, keep old "Clone" if update would empty it (#1170) (#1195) ## [0.15.4](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.4) - 2022-09-06 - BUGFIXES - Extract commit message from branch creation (#1150) (#1153) - Respect WOODPECKER_GITEA_SKIP_VERIFY (#1152) (#1151) - update golang.org/x/crypto (#1124) - Implement Refresher for GitLab (#1031) (#1120) - Make returned proc list to be returned always in correct order (#1060) (#1065) - Update type of 'log_data' from blob to longblob (#1050) (#1052) - Make ListItem component more accessible by using a button tag when clickable (#1044) (#1046) - MISC - Update base images (#1024) (#1025) ## [0.15.3](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.3) - 2022-06-16 - SECURITY - Update github.com/containerd/containerd (#978) (#980) - BUGFIXES - Return to page after clicking login at navbar (#975) (#976) ## [0.15.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.2) - 2022-06-14 - BUGFIXES - Fix uppercase from_secrets (#842) (#925) - Fix key/val format for dind env vars (#889) (#890) - Update helm chart releasing (#882) (#888) - DOCUMENTATION - Fix run_on references with runs_on in docs (#965) ## [0.15.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.1) - 2022-04-13 - SECURITY - Escape html / xml in log view (#879) (#880) - FEATURES - Build multiarch images for server (#821) (#822) - BUGFIXES - Branch list enhancements (#808) (#809) - Get Netrc machine from clone url (#800) (#803) ## [v0.15.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.15.0) - 2022-02-24 - BREAKING - Change paths to use woodpecker instead of drone (#494) - Move plugin config to root.pipeline.[step].settings (#464) - Replace debug with log-level flag (#440) - Change prometheus metrics from `drone_*` to `woodpecker_*` (#439) - Replace DRONE_with CI_ variables in pipeline steps (#427) - Enable pull_request hook by default on repository activation (#420) - Remote Gitea drop basic auth support (#365) - Change pipeline config path resolution (#299) - Remove push, tag and deployment webhook filters (#281) - Clean up config environment variables for server and agent (#218) - SECURITY - Add linter bidichk to prevent malicious utf8 chars (#516) - FEATURES - Show changed files of pipeline in UI (#650) - Show yml config of pipeline in UI (#649) - Multiarch build for cli and agent docker images (#634), (#622) - Get secrets in settings (#604) - Add multi-pipeline support to exec & lint (#568) - Add repo branches endpoint (#481) - Add repo permission endpoint (#436) - Add web-config endpoint (#433) - Replace www-path with www-proxy option for development (#248) - BUGFIXES - Make gRPC error "too many keepalive pings" only show up in trace logs (#787) - WOODPECKER_ENVIRONMENT: ignore items only containing a key and no value (#781) - Fix pipeline timestamps (#730) - Remove "panic()" as much as possible from code (#682) - Send decline events back to UI (#680) - Notice all changed files of all related commits for gitea push webhooks (#675) - Use global branch filter only on events containing branch info (#659) - API GetRepos() return empty list if no active repos exist (#658) - Skip nested GitLab repositories during sync (#656), (#652) - Build proc tree function should not depend on sorted procs list (#647) - Fix sqlite migration on column drop of abnormal schemas (#629) - Fix gRPC incompatibility in helm chart (#627) - Fix new pipeline not published to UI if protected repo mode enabled (#619) - Dont panic, report error back (#582) - Improve status updates (#561) - Let normal repo admins change timeout to lower values (#543) - Fix registry delete (#532) - Fix overflowing commit messages (#528) - Fix passing of netrc credentials to clone step (#492) - Fix various typos (#416) - Append trailing slash to default GH API URL (#411) - Fix filter pipeline config files (#279) - ENHANCEMENTS - Return better error if repo was deleted/renamed (#780) - Add support to set default clone image via environment variable (#769) - Add flag to always authenticate when cloning public repositories from locked down / private only forges (#760) - UI: show date time on hover over time items (#756) - Add repo-link to badge markdown in UI (#753) - Allow specifying dind container in values (#750) - Add page to view all projects of a user / group (#741) - Let non required migration tasks fail and continue (#729) - Improve pipeline compiler (#699) - Support ChangedFiles for GitHub & Gitlab PRs and pushes and Gitea pushes (#697) - Remove unused flags / options (#693) - Automatically determine platform of agent (#690) - Build ref link point to commit not compare if only one commit was pushed (#673) - Hide multi line secrets from log (#671) - Do not exclude repo owner from gated rule (#641) - Add field for image list in Secrets Repo Settings (Web UI) (#638) - Use Woodpecker theme colors on Safari Tab Bar / Header Bar (#632) - Add "woodpeckerci/plugin-docker-buildx" to privileged plugins (#623) - Use gitlab generic webhooks instead of drone-ci-service (#620) - Calculate build number on creation (#615) - Hide gin routes logging on non-debug starts (#603) - Let remove be a remove (#593) - Add flag to set oauth redirect host in dev mode (#586) - Add log-level option to cli (#584) - Improve favicons (#576) - Show icon and index of a pull request in pipelines triggered by pull requests (#575) - Improve secrets tab (#574) - Use monospace font for build logs (#527) - Show environ in every BuildProc (#526) - Drop error only on purpose or else report back or log (#514) - Migrate database backend to Xorm (#474) - Add backend selection for agent (#463) - Switch default git plugin (#449) - Add log level API (#444) - Move entirely to zerolog (#426) - Pass context.Context down (#371) - Extend Logging & Report to WebHook Caller back if pulls are disabled (#369) - If config is no file assume its a folder (#354) - Rename cmd agent and server folders and binaries (#330) - Release Helm charts (#302) - Add flag for specific grpc server addr (#295) - Add option to charts, to pass in topology pod constraints (#262) - Use server-host as source for public links and warn if it is set to localhost (#251) - Rewrite of UI (#245) - REFACTOR - Remove github.com/kr/pretty in favor of assert.EqualValues() (#564) - Simplify web router code (#541) - Server obtain remote from glob config not from context (#540) - Serve index.html directly without template (#539) - Add linter revive, unused, ineffassign, varcheck, structcheck, staticcheck, whitespace, misspell (#550), (#551), (#554), (#538), (#537), (#535), (#531), (#530) - Rename struct field and add new types into server/model's (#523) - Update database in one transaction on syncing user repositories (#513) - Format code with 'simplify' flag and check via CI (#509) - Use Goblin Assert as intended (#501) - Embedding libcompose types for yaml parsing (#495) - Use std method to get SystemCertPool (#488) - Upgrade urfave/cli to v2 (#483) - Remove some wrapper and make code more readable (#478) - More logging and refactor (#457) - Simplify routes (#437) - Move api-routes to separate file (#434) - Rename drone-go to woodpecker-go (#390) - Remove ghodss/yaml (#384) - Move model/ to server/model/ (#366) - Use moby definitions for docker pipeline backend (#364) - Rewrite Gitlab Remote (#358) - Update Generated Proto Code (#351) - Remove legacy/unused code + misc cleanups (#331) - CLI use version from version/version.go (#329) - Move cli/drone/ to cli/ (#329) - Cleanup Code (#348) - Move cncd/pipeline/pipeline/ to pipeline/ (#347) - Move cncd/{logging,pubsub,queue}/ to server/{logging,pubsub,queue}/ (#346) - Move remote/ to server/remote/ (#344) - Move plugins/ to server/plugins/ (#343) - Move store/ to server/store/ (#341) - Move router/ to server/router/ (#339) - Create agent/ package for backend agnostic logic (#338) - Reorganize into server/{api,grpc,shared} packages (#337) - TESTING - Add tests framework for storage migration (#630) - Add more golangci-lint linters & sort them (#499) (#502) - Compile on pull too (#287) - DOCUMENTATION - Add note about Gitlab & Gitea internal connections to docs (#711) - Add registries docs (#679) - Add documentation of all agent configuration options (#667) - Add `repo` to `when` block (#642) - Add development docs (#610) - Clarify Docs on Docker for new users in intro (#606) - Update Documentation (fix diffs and add settings) (#569) - Add notice of supported YAML versions in docs (#556) - Update Agent and Pipeline syntax documentation (#506) - Update docs about selecting agent based on platform (#470) - Add plugin marketplace (for official plugins) (#451) - Add search to docs (#448) - Add image migration docs (#406) - Add security policy (#396) - Explain open registration setting (#361) - Add json schema and cli lint command (#342) - Improve docs deployment (#333) - Improve plugin docs (#313) - Add Support section to README (#310) - Community Guide (#296) - Migrate docs framework to Docusaurus (#282) - Use woodpecker env variable instead of drone in docker-compose (#264) - MISC - Add support for building in docker (#759) - Compile for more platforms on release (#703) - Build agent for multiple platforms (arm, arm64, amd64, linux, windows, darwin) (#408) - Release deb, rpm bundles (#405) - Release cli images (#404) - Publish alpine container (#398) - Migrate go-docker to docker/docker (#363) - Use go's vendoring (#284) ## [v0.14.4](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.4) - 2022-01-31 - BUGFIXES - Docker Images use golang image for ca-certificates (#608) ## [v0.14.3](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.3) - 2021-10-30 - BUGFIXES - Add flag for not fetching permissions (FlatPermissions) (#491) - Gitea use default branch (#480) (#482) - Fix repo access (#476) (#477) - ENHANCEMENTS - Use go embed for web files and remove httptreemux (#382) (#489) ## [v0.14.2](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.2) - 2021-10-19 - BUGFIXES - Fix sanitizePath (#326) (aa4fa9aab3) - Fix json tag for `Pos` at struct `Line` (#422) (#424) - Fix channel buffer used with signal.Notify (#421) (#423) - ENHANCEMENTS - Support recursive glob for path conditions (#327) (#412) - TESTING - Add TestPipelineName to procBuilder_test.go (#461) (#455) ## [v0.14.1](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.1) - 2021-09-21 - SECURITY - Migrate jwt token lib (#332) - BUGFIXES - Increase allowed length for user token in db (#328) - Fix cli matrix filter (#311) - Fix ignore pushes to tags for gitea (#289) - Fix use custom config path to sanitize build names (#280) ## [v0.14.0](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.14.0) - 2021-08-01 - FEATURES - Add OAuth2 Support for Gitea Remote (#226) - Add support for path-prefix condition (#174) - BUGFIXES - Allow multi pipeline file to be named .drone.yml (#250) - Fix release-server make target by build server with correct option (#237) - Fix Gitea unable to login on 0.12.0+ with error "cannot authenticate user. 403 Forbidden" (#221) - ENHANCEMENTS - Update / Remove drone dependencies (#236) - Add support to gitea remote for path-prefix condition (#235) - Enable go vet for ci (#230) - Enforce code format (#228) - Add multi-pipeline to Gitea (#225) - Move flag definitions into extra files (#215) - Remove unused code in server (#213) - Docs URL configuration (#206) - Filter main branch (#205) - Fix multi pipeline bug when a pipeline depends on two other pipelines (#201) - Using configured server URL instead of obtained from request (#175) - DOCUMENTATION - Switch in docs to new docker hub image repo (#227) - Use WOODPECKER_ env vars in docs (#211) - Also show WOODPECKER_HOST and WOODPECKER_SERVER_HOST environment variables in log messages (#208) - Move woodpecker to dedicated organisation on github (#202) - MISC - Add chart for installing woodpecker server and agent (#199) ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2018 Drone.IO Inc. Copyright 2020 Woodpecker Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ # renovate: datasource=github-releases depName=mvdan/gofumpt GOFUMPT_VERSION := v0.10.0 # renovate: datasource=github-releases depName=golangci/golangci-lint GOLANGCI_LINT_VERSION := v2.12.2 # renovate: datasource=docker depName=docker.io/techknowlogick/xgo XGO_VERSION := go-1.26.x GO_PACKAGES ?= $(shell go list ./... | grep -v /vendor/) TARGETOS ?= $(shell go env GOOS) TARGETARCH ?= $(shell go env GOARCH) BIN_SUFFIX := ifeq ($(TARGETOS),windows) BIN_SUFFIX := .exe endif DIST_DIR ?= dist VERSION ?= next VERSION_NUMBER ?= 0.0.0 CI_COMMIT_SHA ?= $(shell git rev-parse HEAD) # it's a tagged release ifneq ($(CI_COMMIT_TAG),) VERSION := $(CI_COMMIT_TAG:v%=%) VERSION_NUMBER := ${CI_COMMIT_TAG:v%=%} else # append commit-sha to next version ifeq ($(VERSION),next) VERSION := $(shell echo "next-$(shell echo ${CI_COMMIT_SHA} | cut -c -10)") endif # append commit-sha to release branch version ifeq ($(shell echo ${CI_COMMIT_BRANCH} | cut -c -9),release/v) VERSION := $(shell echo "$(shell echo ${CI_COMMIT_BRANCH} | cut -c 10-)-$(shell echo ${CI_COMMIT_SHA} | cut -c -10)") endif endif TAGS ?= LDFLAGS := -X go.woodpecker-ci.org/woodpecker/v3/version.Version=${VERSION} STATIC_BUILD ?= true ifeq ($(STATIC_BUILD),true) LDFLAGS := -s -w -extldflags "-static" $(LDFLAGS) endif CGO_ENABLED ?= 1 # only used to compile server HAS_GO = $(shell hash go > /dev/null 2>&1 && echo "GO" || echo "NOGO" ) ifeq ($(HAS_GO),GO) CGO_CFLAGS ?= $(shell go env CGO_CFLAGS) endif CGO_CFLAGS ?= # If the first argument is "in_docker"... ifeq (in_docker,$(firstword $(MAKECMDGOALS))) # use the rest as arguments for "in_docker" MAKE_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) # Ignore the next args $(eval $(MAKE_ARGS):;@:) in_docker: @[ "1" -eq "$(shell docker image ls woodpecker/make:local -a | wc -l)" ] && docker buildx build -f ./docker/Dockerfile.make -t woodpecker/make:local --load . || echo reuse existing docker image @echo run in docker: @docker run -it \ --user $(shell id -u):$(shell id -g) \ -e VERSION="$(VERSION)" \ -e CI_COMMIT_SHA="$(CI_COMMIT_SHA)" \ -e TARGETOS="linux" \ -e TARGETARCH="$(TARGETARCH)" \ -e CGO_ENABLED="$(CGO_ENABLED)" \ -v $(PWD):/build --rm woodpecker/make:local make $(MAKE_ARGS) else # Proceed with normal make ##@ General .PHONY: all all: help .PHONY: version version: ## Print the current version @echo ${VERSION} # The help target prints out all targets with their descriptions organized # beneath their categories. The categories are represented by '##@' and the # target descriptions by '##'. The awk commands is responsible for reading the # entire set of makefiles included in this invocation, looking for lines of the # file as xyz: ## something, and then pretty-format the target and help. Then, # if there's a line with ##@ something, that gets pretty-printed as a category. # More info on the usage of ANSI control characters for terminal formatting: # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters # More info on the awk command: # http://linuxcommand.org/lc3_adv_awk.php .PHONY: help help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) .PHONY: vendor vendor: ## Update the vendor directory go mod tidy go mod vendor format: install-gofumpt ## Format source code @gofumpt -extra -w . .PHONY: clean clean: ## Clean build artifacts go clean -i ./... rm -rf build @[ "1" != "$(shell docker image ls woodpecker/make:local -a | wc -l)" ] && docker image rm woodpecker/make:local || echo no docker image to clean .PHONY: clean-all clean-all: clean ## Clean all artifacts rm -rf ${DIST_DIR} web/dist docs/build docs/node_modules web/node_modules # delete generated rm -rf docs/docs/40-cli.md docs/openapi.json .PHONY: generate generate: install-mockery generate-openapi ## Run all code generations mockery CGO_ENABLED=0 go generate ./... generate-openapi: ## Run openapi code generation and format it CGO_ENABLED=0 go run github.com/swaggo/swag/cmd/swag fmt --exclude rpc/proto CGO_ENABLED=0 go generate cmd/server/openapi.go generate-license-header: install-addlicense addlicense -c "Woodpecker Authors" -l apache -ignore "vendor/**" -ignore cmd/server/openapi/docs.go **/*.go check-xgo: ## Check if xgo is installed @hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ $(GO) install src.techknowlogick.com/xgo@latest; \ fi install-golangci-lint: @hash golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) ; \ fi install-gofumpt: @hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION); \ fi install-addlicense: @hash addlicense > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ go install github.com/google/addlicense@latest; \ fi install-mockery: @hash mockery > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ go install github.com/vektra/mockery/v3@latest; \ fi install-protoc-gen-go: @hash protoc-gen-go > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest; \ fi ; \ hash protoc-gen-go-grpc > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest; \ fi .PHONY: install-tools install-tools: install-golangci-lint install-gofumpt install-addlicense install-mockery install-protoc-gen-go ## Install development tools ui-dependencies: ## Install UI dependencies (cd web/; pnpm install --frozen-lockfile) ##@ Test .PHONY: lint lint: install-golangci-lint ## Lint code @echo "Running golangci-lint" golangci-lint run lint-ui: ui-dependencies ## Lint UI code (cd web/; pnpm lint --quiet) test-agent: ## Test agent code go test -race -cover -coverprofile agent-coverage.out -timeout 60s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/cmd/agent go.woodpecker-ci.org/woodpecker/v3/agent/... test-server: ## Test server code go test -race -cover -coverprofile server-coverage.out -timeout 60s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/cmd/server $(shell go list go.woodpecker-ci.org/woodpecker/v3/server/... | grep -v '/store') test-cli: ## Test cli code go test -race -cover -coverprofile cli-coverage.out -timeout 60s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/cmd/cli go.woodpecker-ci.org/woodpecker/v3/cli/... test-server-datastore: ## Test server datastore go test -timeout 300s -tags 'test $(TAGS)' -run TestMigrate go.woodpecker-ci.org/woodpecker/v3/server/store/... go test -race -timeout 120s -tags 'test $(TAGS)' -skip TestMigrate go.woodpecker-ci.org/woodpecker/v3/server/store/... test-server-datastore-coverage: ## Test server datastore with coverage report go test -race -cover -coverprofile datastore-coverage.out -timeout 300s -tags 'test $(TAGS)' go.woodpecker-ci.org/woodpecker/v3/server/store/... test-ui: ui-dependencies ## Test UI code (cd web/; pnpm run lint) (cd web/; pnpm run format:check) (cd web/; pnpm run typecheck) (cd web/; pnpm run test) test-lib: ## Test lib code go test -race -cover -coverprofile coverage.out -timeout 60s -tags 'test $(TAGS)' $(shell go list ./... | grep -v '/cmd\|/agent\|/cli\|/server') test-e2e: ## Test by running yaml config and compare expected result go test -race -cover -coverpkg=./... -coverprofile e2e-coverage.out -timeout 60s -tags 'test $(TAGS)' ./e2e/... .PHONY: test test: test-agent test-server test-server-datastore test-cli test-lib test-e2e ## Run all tests ##@ Build build-ui: ## Build UI (cd web/; pnpm install --frozen-lockfile; pnpm build) build-server: build-ui generate-openapi ## Build server CGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags '$(TAGS)' -ldflags '${LDFLAGS}' -o ${DIST_DIR}/woodpecker-server${BIN_SUFFIX} go.woodpecker-ci.org/woodpecker/v3/cmd/server build-agent: ## Build agent CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags '$(TAGS)' -ldflags '${LDFLAGS}' -o ${DIST_DIR}/woodpecker-agent${BIN_SUFFIX} go.woodpecker-ci.org/woodpecker/v3/cmd/agent build-cli: ## Build cli CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -tags '$(TAGS)' -ldflags '${LDFLAGS}' -o ${DIST_DIR}/woodpecker-cli${BIN_SUFFIX} go.woodpecker-ci.org/woodpecker/v3/cmd/cli build-tarball: ## Build tar archive mkdir -p ${DIST_DIR} && tar chzvf ${DIST_DIR}/woodpecker-src.tar.gz \ --exclude="*.exe" \ --exclude="./.pnpm-store" \ --exclude="node_modules" \ --exclude="./dist" \ --exclude="./data" \ --exclude="./build" \ --exclude="./.git" \ . .PHONY: build build: build-agent build-server build-cli ## Build all binaries .PHONY: release-frontend release-frontend: build-ui ## Build frontend cross-compile-server: ## Cross compile the server $(foreach platform,$(subst ;, ,$(PLATFORMS)),\ TARGETOS=$(firstword $(subst |, ,$(platform))) \ TARGETARCH_XGO=$(subst arm64/v8,arm64,$(subst arm/v7,arm-7,$(word 2,$(subst |, ,$(platform))))) \ TARGETARCH_BUILDX=$(subst arm64/v8,arm64,$(subst arm/v7,arm,$(word 2,$(subst |, ,$(platform))))) \ make release-server-xgo || exit 1; \ ) tree ${DIST_DIR} release-server-xgo: check-xgo ## Create server binaries for release using xgo @echo "Building for:" @echo "os:$(TARGETOS)" @echo "arch orgi:$(TARGETARCH)" @echo "arch (xgo):$(TARGETARCH_XGO)" @echo "arch (buildx):$(TARGETARCH_BUILDX)" # build via xgo CGO_CFLAGS="$(CGO_CFLAGS)" xgo -go $(XGO_VERSION) -dest ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX) -tags 'netgo osusergo grpcnotrace $(TAGS)' -ldflags '-linkmode external $(LDFLAGS)' -targets '$(TARGETOS)/$(TARGETARCH_XGO)' -out woodpecker-server -pkg cmd/server . # move binary into subfolder depending on target os and arch @if [ "$${XGO_IN_XGO:-0}" -eq "1" ]; then \ echo "inside xgo image"; \ mkdir -p ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX); \ mv -vf /build/woodpecker-server* ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX); \ else \ echo "outside xgo image"; \ [ -f "${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX)" ] && rm -v ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX); \ mv -v ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_XGO)/woodpecker-server* ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server$(BIN_SUFFIX); \ fi # if enabled package it in an archive @if [ "$${ARCHIVE_IT:-0}" -eq "1" ]; then \ if [ "$(BIN_SUFFIX)" = ".exe" ]; then \ rm -f ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).zip; \ zip -j ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).zip ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX)/woodpecker-server.exe; \ else \ tar -cvzf ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).tar.gz -C ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH_BUILDX) woodpecker-server$(BIN_SUFFIX); \ fi; \ else \ echo "skip creating '${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH_BUILDX).tar.gz'"; \ fi release-server: ## Create server binaries for release # compile GOOS=$(TARGETOS) GOARCH=$(TARGETARCH) CGO_ENABLED=${CGO_ENABLED} go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH)/woodpecker-server$(BIN_SUFFIX) go.woodpecker-ci.org/woodpecker/v3/cmd/server # tar binary files if [ "$(BIN_SUFFIX)" == ".exe" ]; then \ zip -j ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH).zip ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH)/woodpecker-server.exe; \ else \ tar -cvzf ${DIST_DIR}/woodpecker-server_$(TARGETOS)_$(TARGETARCH).tar.gz -C ${DIST_DIR}/server/$(TARGETOS)_$(TARGETARCH) woodpecker-server$(BIN_SUFFIX); \ fi release-agent: ## Create agent binaries for release # compile GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_amd64/woodpecker-agent go.woodpecker-ci.org/woodpecker/v3/cmd/agent GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_arm64/woodpecker-agent go.woodpecker-ci.org/woodpecker/v3/cmd/agent GOOS=linux GOARCH=riscv64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_riscv64/woodpecker-agent go.woodpecker-ci.org/woodpecker/v3/cmd/agent GOOS=linux GOARCH=arm CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/linux_arm/woodpecker-agent go.woodpecker-ci.org/woodpecker/v3/cmd/agent GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/windows_amd64/woodpecker-agent.exe go.woodpecker-ci.org/woodpecker/v3/cmd/agent GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/darwin_amd64/woodpecker-agent go.woodpecker-ci.org/woodpecker/v3/cmd/agent GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -tags 'grpcnotrace $(TAGS)' -o ${DIST_DIR}/agent/darwin_arm64/woodpecker-agent go.woodpecker-ci.org/woodpecker/v3/cmd/agent # tar binary files tar -cvzf ${DIST_DIR}/woodpecker-agent_linux_amd64.tar.gz -C ${DIST_DIR}/agent/linux_amd64 woodpecker-agent tar -cvzf ${DIST_DIR}/woodpecker-agent_linux_arm64.tar.gz -C ${DIST_DIR}/agent/linux_arm64 woodpecker-agent tar -cvzf ${DIST_DIR}/woodpecker-agent_linux_riscv64.tar.gz -C ${DIST_DIR}/agent/linux_riscv64 woodpecker-agent tar -cvzf ${DIST_DIR}/woodpecker-agent_linux_arm.tar.gz -C ${DIST_DIR}/agent/linux_arm woodpecker-agent tar -cvzf ${DIST_DIR}/woodpecker-agent_darwin_amd64.tar.gz -C ${DIST_DIR}/agent/darwin_amd64 woodpecker-agent tar -cvzf ${DIST_DIR}/woodpecker-agent_darwin_arm64.tar.gz -C ${DIST_DIR}/agent/darwin_arm64 woodpecker-agent # zip binary files rm -f ${DIST_DIR}/woodpecker-agent_windows_amd64.zip zip -j ${DIST_DIR}/woodpecker-agent_windows_amd64.zip ${DIST_DIR}/agent/windows_amd64/woodpecker-agent.exe release-cli: ## Create cli binaries for release # compile GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_amd64/woodpecker-cli go.woodpecker-ci.org/woodpecker/v3/cmd/cli GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_arm64/woodpecker-cli go.woodpecker-ci.org/woodpecker/v3/cmd/cli GOOS=linux GOARCH=riscv64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_riscv64/woodpecker-cli go.woodpecker-ci.org/woodpecker/v3/cmd/cli GOOS=linux GOARCH=arm CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/linux_arm/woodpecker-cli go.woodpecker-ci.org/woodpecker/v3/cmd/cli GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/windows_amd64/woodpecker-cli.exe go.woodpecker-ci.org/woodpecker/v3/cmd/cli GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/darwin_amd64/woodpecker-cli go.woodpecker-ci.org/woodpecker/v3/cmd/cli GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -ldflags '${LDFLAGS}' -o ${DIST_DIR}/cli/darwin_arm64/woodpecker-cli go.woodpecker-ci.org/woodpecker/v3/cmd/cli # tar binary files tar -cvzf ${DIST_DIR}/woodpecker-cli_linux_amd64.tar.gz -C ${DIST_DIR}/cli/linux_amd64 woodpecker-cli tar -cvzf ${DIST_DIR}/woodpecker-cli_linux_arm64.tar.gz -C ${DIST_DIR}/cli/linux_arm64 woodpecker-cli tar -cvzf ${DIST_DIR}/woodpecker-cli_linux_riscv64.tar.gz -C ${DIST_DIR}/cli/linux_riscv64 woodpecker-cli tar -cvzf ${DIST_DIR}/woodpecker-cli_linux_arm.tar.gz -C ${DIST_DIR}/cli/linux_arm woodpecker-cli tar -cvzf ${DIST_DIR}/woodpecker-cli_darwin_amd64.tar.gz -C ${DIST_DIR}/cli/darwin_amd64 woodpecker-cli tar -cvzf ${DIST_DIR}/woodpecker-cli_darwin_arm64.tar.gz -C ${DIST_DIR}/cli/darwin_arm64 woodpecker-cli # zip binary files rm -f ${DIST_DIR}/woodpecker-cli_windows_amd64.zip zip -j ${DIST_DIR}/woodpecker-cli_windows_amd64.zip ${DIST_DIR}/cli/windows_amd64/woodpecker-cli.exe release-checksums: ## Create checksums for all release files # generate shas for tar files (cd ${DIST_DIR}/; sha256sum *.* > checksums.txt) .PHONY: release release: release-frontend release-server release-agent release-cli ## Release all binaries bundle-prepare: ## Prepare the bundles CGO_ENABLED=0 go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.45.0 bundle-agent: bundle-prepare ## Create bundles for agent VERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/agent.yaml --target ${DIST_DIR} --packager deb VERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/agent.yaml --target ${DIST_DIR} --packager rpm bundle-server: bundle-prepare ## Create bundles for server VERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/server.yaml --target ${DIST_DIR} --packager deb VERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/server.yaml --target ${DIST_DIR} --packager rpm bundle-cli: bundle-prepare ## Create bundles for cli VERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/cli.yaml --target ${DIST_DIR} --packager deb VERSION_NUMBER=$(VERSION_NUMBER) nfpm package --config ./nfpm/cli.yaml --target ${DIST_DIR} --packager rpm .PHONY: bundle bundle: bundle-agent bundle-server bundle-cli ## Create all bundles .PHONY: spellcheck spellcheck: pnpx cspell lint --no-progress --gitignore '{**,.*}/{*,.*}' tree --gitignore \ -I 012_columns_rename_procs_to_steps.go \ -I versioned_docs -I '*opensource.svg' | \ pnpx cspell lint --no-progress stdin ##@ Docs .PHONY: docs-dependencies docs-dependencies: ## Install docs dependencies (cd docs/; pnpm install --frozen-lockfile) .PHONY: generate-docs generate-docs: ## Generate docs (currently only for the cli) CGO_ENABLED=0 go generate cmd/cli/app.go CGO_ENABLED=0 go generate cmd/server/openapi.go .PHONY: build-docs build-docs: generate-docs docs-dependencies ## Build the docs (cd docs/; pnpm build) ##@ Man Pages .PHONY: man-cli man-cli: ## Generate man pages for cli mkdir -p dist/ && CGO_ENABLED=0 go run -tags man cmd/cli/man.go cmd/cli/app.go > dist/woodpecker-cli.man.1 && gzip -9 -f dist/woodpecker-cli.man.1 .PHONY: man-agent man-agent: ## Generate man pages for agent mkdir -p dist/ && CGO_ENABLED=0 go run -tags man cmd/agent/man.go > dist/woodpecker-agent.man.1 && gzip -9 -f dist/woodpecker-agent.man.1 .PHONY: man-server man-server: ## Generate man pages for server mkdir -p dist/ && CGO_ENABLED=0 go run -tags man go.woodpecker-ci.org/woodpecker/v3/cmd/server > dist/woodpecker-server.man.1 && gzip -9 -f dist/woodpecker-server.man.1 .PHONY: man man: man-cli man-agent man-server ## Generate all man pages endif ================================================ FILE: README.md ================================================ # Woodpecker

Woodpecker


Pipeline Status Code coverage Translation status Matrix space Go Report Card go reference GitHub release Docker pulls License: Apache-2.0 OpenSSF best practices pre-commit.ci


Woodpecker is a simple, yet powerful CI/CD engine with great extensibility. ![woodpecker](docs/woodpecker.png) ## Installation & Resources Woodpecker can be installed in various ways (see the [Installation Instructions](https://woodpecker-ci.org/docs/administration/general)) and runs with SQLite as database by default. It requires around 100 MB of RAM (Server) and 30 MB (Agent) at runtime in idle mode. ## Support You can support the project by becoming a backer on [Open Collective](https://opencollective.com/woodpecker-ci#category-CONTRIBUTE) or via [GitHub Sponsors](https://github.com/sponsors/woodpecker-ci). Open Collective backers ## Documentation Our documentation can be found at . ## Translation We have a self-hosted [Weblate](https://weblate.org/en/) instance at [translate.woodpecker-ci.org](https://translate.woodpecker-ci.org). An overview of the current translation state is available at . ## Public Woodpecker Instances Woodpecker is used as the main CI/CD engine at [Codeberg](https://codeberg.org), an alternative Git hosting platform with a focus on privacy and free software development. ## Plugins Woodpecker can be extended via plugins. The [plugin overview website](https://woodpecker-ci.org/plugins) helps browsing available plugins. It combines both plugins by the Woodpecker core team and community-maintained ones. ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=woodpecker-ci/woodpecker&type=Date)](https://star-history.com/#woodpecker-ci/woodpecker&Date) ## License Woodpecker is Apache 2.0 licensed. The source files have a header indicating which license they are under and what copyrights apply. Everything in `docs/` is licensed under the Creative Commons Attribution-ShareAlike 4.0 International Public License. ================================================ FILE: agent/log/line_writer.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2011 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log import ( "io" "strings" "sync" "time" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/pipeline/shared" "go.woodpecker-ci.org/woodpecker/v3/rpc" ) // LineWriter sends logs to the client. type LineWriter struct { sync.Mutex peer rpc.Peer stepUUID string num int startTime time.Time replacer *strings.Replacer } // NewLineWriter returns a new line reader. func NewLineWriter(peer rpc.Peer, stepUUID string, secret ...string) io.Writer { lw := &LineWriter{ peer: peer, stepUUID: stepUUID, startTime: time.Now().UTC(), replacer: shared.NewSecretsReplacer(secret), } return lw } func (w *LineWriter) Write(p []byte) (n int, err error) { data := string(p) if w.replacer != nil { data = w.replacer.Replace(data) } log.Trace().Str("step-uuid", w.stepUUID).Msgf("grpc write line: %s", data) line := &rpc.LogEntry{ Data: []byte(strings.TrimSuffix(data, "\n")), // remove trailing newline StepUUID: w.stepUUID, Time: int64(time.Since(w.startTime).Seconds()), Type: rpc.LogEntryStdout, Line: w.num, } w.num++ w.peer.EnqueueLog(line) return len(data), nil } ================================================ FILE: agent/log/line_writer_test.go ================================================ // Copyright 2019 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/agent/log" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/rpc/mocks" ) func TestLineWriter(t *testing.T) { peer := mocks.NewMockPeer(t) peer.On("EnqueueLog", mock.Anything) secrets := []string{"world"} lw := log.NewLineWriter(peer, "e9ea76a5-44a1-4059-9c4a-6956c478b26d", secrets...) _, err := lw.Write([]byte("hello world\n")) assert.NoError(t, err) _, err = lw.Write([]byte("the previous line had no newline at the end")) assert.NoError(t, err) peer.AssertCalled(t, "EnqueueLog", &rpc.LogEntry{ StepUUID: "e9ea76a5-44a1-4059-9c4a-6956c478b26d", Time: 0, Type: rpc.LogEntryStdout, Line: 0, Data: []byte("hello ********"), }) peer.AssertCalled(t, "EnqueueLog", &rpc.LogEntry{ StepUUID: "e9ea76a5-44a1-4059-9c4a-6956c478b26d", Time: 0, Type: rpc.LogEntryStdout, Line: 1, Data: []byte("the previous line had no newline at the end"), }) peer.AssertExpectations(t) } ================================================ FILE: agent/logger.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agent import ( "io" "github.com/rs/zerolog" "go.woodpecker-ci.org/woodpecker/v3/agent/log" "go.woodpecker-ci.org/woodpecker/v3/pipeline" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/logging" pipeline_utils "go.woodpecker-ci.org/woodpecker/v3/pipeline/utils" "go.woodpecker-ci.org/woodpecker/v3/rpc" ) func (r *Runner) createLogger(_logger zerolog.Logger, workflow *rpc.Workflow) logging.Logger { return func(step *backend_types.Step, rc io.ReadCloser) error { defer rc.Close() logger := _logger.With(). Str("image", step.Image). Logger() var secrets []string for _, secret := range workflow.Config.Secrets { secrets = append(secrets, secret.Value) } logger.Debug().Msg("log stream opened") logStream := log.NewLineWriter(r.client, step.UUID, secrets...) if err := pipeline_utils.CopyLineByLine(logStream, rc, pipeline.MaxLogLineLength); err != nil { logger.Error().Err(err).Msg("copy limited logStream part") } logger.Debug().Msg("log stream copied, close ...") return nil } } ================================================ FILE: agent/rpc/auth_client_grpc.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "time" "google.golang.org/grpc" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" ) const authClientTimeout = time.Second * 5 type AuthClient struct { client proto.WoodpeckerAuthClient conn *grpc.ClientConn agentToken string agentID int64 } func NewAuthGrpcClient(conn *grpc.ClientConn, agentToken string, agentID int64) *AuthClient { client := new(AuthClient) client.client = proto.NewWoodpeckerAuthClient(conn) client.conn = conn client.agentToken = agentToken client.agentID = agentID return client } func (c *AuthClient) AgentID() int64 { return c.agentID } func (c *AuthClient) Auth(ctx context.Context) (string, int64, error) { ctx, cancel := context.WithTimeout(ctx, authClientTimeout) defer cancel() req := &proto.AuthRequest{ AgentToken: c.agentToken, AgentId: c.agentID, } res, err := c.client.Auth(ctx, req) if err != nil { return "", -1, err } c.agentID = res.GetAgentId() return res.GetAccessToken(), c.agentID, nil } ================================================ FILE: agent/rpc/auth_client_grpc_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "testing" "github.com/stretchr/testify/assert" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) func TestAuthClientAgentID(t *testing.T) { conn, err := grpc.NewClient("localhost:0", grpc.WithTransportCredentials(insecure.NewCredentials())) assert.NoError(t, err) defer conn.Close() client := NewAuthGrpcClient(conn, "test-token", 42) assert.Equal(t, int64(42), client.AgentID()) } ================================================ FILE: agent/rpc/auth_interceptor.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "time" "github.com/rs/zerolog/log" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) // AuthInterceptor is a client interceptor for authentication. type AuthInterceptor struct { authClient *AuthClient accessToken string } // NewAuthInterceptor returns a new auth interceptor. func NewAuthInterceptor(ctx context.Context, authClient *AuthClient, refreshDuration time.Duration) (*AuthInterceptor, error) { interceptor := &AuthInterceptor{ authClient: authClient, } err := interceptor.scheduleRefreshToken(ctx, refreshDuration) if err != nil { return nil, err } return interceptor, nil } // Unary returns a client interceptor to authenticate unary RPC. func (interceptor *AuthInterceptor) Unary() grpc.UnaryClientInterceptor { return func( ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption, ) error { return invoker(interceptor.attachToken(ctx), method, req, reply, cc, opts...) } } // Stream returns a client interceptor to authenticate stream RPC. func (interceptor *AuthInterceptor) Stream() grpc.StreamClientInterceptor { return func( ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption, ) (grpc.ClientStream, error) { return streamer(interceptor.attachToken(ctx), desc, cc, method, opts...) } } func (interceptor *AuthInterceptor) attachToken(ctx context.Context) context.Context { return metadata.AppendToOutgoingContext(ctx, "token", interceptor.accessToken) } func (interceptor *AuthInterceptor) scheduleRefreshToken(ctx context.Context, refreshInterval time.Duration) error { err := interceptor.refreshToken(ctx) if err != nil { return err } go func() { wait := refreshInterval for { select { case <-ctx.Done(): return case <-time.After(wait): err := interceptor.refreshToken(ctx) if err != nil { wait = time.Second } else { wait = refreshInterval } } } }() return nil } func (interceptor *AuthInterceptor) refreshToken(ctx context.Context) error { accessToken, _, err := interceptor.authClient.Auth(ctx) if err != nil { return err } interceptor.accessToken = accessToken log.Trace().Msg("token refreshed") return nil } ================================================ FILE: agent/rpc/client_grpc.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "encoding/json" "errors" "strings" "time" "github.com/cenkalti/backoff/v5" "github.com/rs/zerolog/log" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/connectivity" "google.golang.org/grpc/status" grpc_proto "google.golang.org/protobuf/proto" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" ) var ( ErrConnectionLost = errors.New("connection to server lost") errNotConnected = errors.New("grpc: not connected") ) const ( // Set grpc version on compile time to compare against server version response. ClientGrpcVersion int32 = proto.Version // Maximum size of an outgoing log message. // Picked to prevent it from going over GRPC size limit (4 MiB) with a large safety margin. maxLogBatchSize int = 1 * 1024 * 1024 // Maximum amount of time between sending consecutive batched log messages. // Controls the delay between the CI job generating a log record, and web users receiving it. maxLogFlushPeriod time.Duration = time.Second ) type client struct { client proto.WoodpeckerClient conn *grpc.ClientConn logs chan *proto.LogEntry // connectionRetryTimeout is the maximum time to wait for a connection to be // restored before the agent gives up and exits. Zero means infinite. // Maps directly onto backoff.WithMaxElapsedTime. connectionRetryTimeout time.Duration } // NewGrpcClient returns a new grpc Client. func NewGrpcClient(ctx context.Context, conn *grpc.ClientConn, opts ...ClientOption) rpc.Peer { client := new(client) client.client = proto.NewWoodpeckerClient(conn) client.conn = conn client.logs = make(chan *proto.LogEntry, 10) // max memory use: 10 lines * 1 MiB for _, opt := range opts { opt(client) } go client.processLogs(ctx) return client } type ClientOption func(c *client) func SetConnectionRetryTimeout(d time.Duration) ClientOption { if d == 0 { log.Warn().Msg("connection retry timeout set to infinite") } return func(c *client) { c.connectionRetryTimeout = d } } // IsConnected reports whether the underlying gRPC connection is currently up. // It is a pure observer with no side effects. func (c *client) IsConnected() bool { state := c.conn.GetState() return state == connectivity.Ready || state == connectivity.Idle } // retryOpts returns the backoff options used for every retry loop in this // file. The exponential backoff parameters preserve the original tuning // (10 ms initial, 10 s cap), and connectionRetryTimeout is wired straight into // WithMaxElapsedTime — when it elapses, backoff.Retry returns the last error, // which we translate into ErrConnectionLost in retryRPC. func (c *client) retryOpts(op string) []backoff.RetryOption { b := backoff.NewExponentialBackOff() b.MaxInterval = 10 * time.Second //nolint:mnd b.InitialInterval = 10 * time.Millisecond //nolint:mnd notify := func(err error, next time.Duration) { // The "too_many_pings" GOAWAY is well-known noise; demote to trace. // See https://github.com/woodpecker-ci/woodpecker/issues/717 if strings.Contains(err.Error(), `"too_many_pings"`) { log.Trace().Err(err).Dur("retry_in", next).Msgf("grpc: %s(): too many keepalive pings without sending data", op) return } if errors.Is(err, errNotConnected) { log.Warn().Dur("retry_in", next).Msgf("grpc: %s() waiting for server connection...", op) return } log.Warn().Err(err).Dur("retry_in", next).Msgf("grpc error: %s(): code: %v", op, status.Code(err)) } return []backoff.RetryOption{ backoff.WithBackOff(b), backoff.WithMaxElapsedTime(c.connectionRetryTimeout), backoff.WithNotify(notify), } } // retryRPC is the workhorse used by every RPC method in this file. It runs op // under backoff.Retry with the standard options, and translates the few // special outcomes the callers care about: // // - op succeeds -> (result, nil) // - ctx canceled -> (zero, nil) same contract as before // - MaxElapsedTime hit -> (zero, ErrConnectionLost) // - permanent (fatal) -> (zero, underlying err) // // The op closure is responsible for: // - returning errNotConnected when IsConnected() is false (Retry will sleep // and call again — same effect as the old "if !c.IsConnected()" preamble) // - returning backoff.Permanent(err) for unrecoverable gRPC codes // - returning the raw error for retryable codes (Aborted/DataLoss/...) func retryRPC[T any](ctx context.Context, c *client, opName string, op backoff.Operation[T]) (T, error) { res, err := backoff.Retry(ctx, op, c.retryOpts(opName)...) if err == nil { return res, nil } var zero T // Context canceled while inside Retry: callers historically swallowed this // and returned a zero-value error, so preserve that contract. if ctxErr := context.Cause(ctx); ctxErr != nil && errors.Is(err, ctxErr) { log.Debug().Err(err).Msgf("grpc: %s(): context canceled", opName) return zero, nil } // MaxElapsedTime exhausted while we were still in errNotConnected — give up. if errors.Is(err, errNotConnected) { log.Error().Msg("grpc: connection lost, giving up") return zero, ErrConnectionLost } log.Error().Err(err).Msgf("grpc error: %s(): code: %v", opName, status.Code(err)) return zero, err } // classifyRPCErr inspects a gRPC error and returns either the same error (for // retryable codes) or a backoff.Permanent wrapping it (for fatal codes). It is // the single source of truth for which gRPC codes are worth retrying. func classifyRPCErr(ctx context.Context, err error) error { if err == nil { return nil } switch status.Code(err) { case codes.Canceled: // If our own ctx is dead, surface that as the cause so Retry's // context.Cause(ctx) check exits cleanly. Otherwise it's a server-side // cancel that we treat as permanent. if ctx.Err() != nil { return backoff.Permanent(ctx.Err()) } return backoff.Permanent(err) case codes.Aborted, codes.DataLoss, codes.DeadlineExceeded, codes.Internal, codes.Unavailable: return err default: return backoff.Permanent(err) } } // Version returns the server- & grpc-version. func (c *client) Version(ctx context.Context) (*rpc.Version, error) { res, err := c.client.Version(ctx, &proto.Empty{}) if err != nil { return nil, err } return &rpc.Version{ GrpcVersion: res.GrpcVersion, ServerVersion: res.ServerVersion, }, nil } // Next returns the next workflow in the queue. func (c *client) Next(ctx context.Context, filter rpc.Filter) (*rpc.Workflow, error) { req := &proto.NextRequest{Filter: &proto.Filter{Labels: filter.Labels}} res, err := retryRPC(ctx, c, "next", func() (*proto.NextResponse, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.Next(ctx, req) return r, classifyRPCErr(ctx, err) }) if err != nil { return nil, err } if res == nil || res.GetWorkflow() == nil { return nil, nil } w := &rpc.Workflow{ ID: res.GetWorkflow().GetId(), Timeout: res.GetWorkflow().GetTimeout(), Config: new(backend_types.Config), } if err := json.Unmarshal(res.GetWorkflow().GetPayload(), w.Config); err != nil { log.Error().Err(err).Msgf("could not unmarshal workflow config of '%s'", w.ID) } return w, nil } // Wait blocks until the workflow with the given ID is marked as completed or canceled by the server. func (c *client) Wait(ctx context.Context, workflowID string) (canceled bool, err error) { req := &proto.WaitRequest{Id: workflowID} resp, err := retryRPC(ctx, c, "wait", func() (*proto.WaitResponse, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.Wait(ctx, req) return r, classifyRPCErr(ctx, err) }) if err != nil { return false, err } if resp == nil { return false, nil } return resp.GetCanceled(), nil } // Init signals the workflow is initialized. func (c *client) Init(ctx context.Context, workflowID string, state rpc.WorkflowState) error { req := &proto.InitRequest{ Id: workflowID, State: &proto.WorkflowState{ Started: state.Started, Finished: state.Finished, Error: state.Error, Canceled: state.Canceled, }, } _, err := retryRPC(ctx, c, "init", func() (*proto.Empty, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.Init(ctx, req) return r, classifyRPCErr(ctx, err) }) return err } // Done let agent signal to server the workflow has stopped. func (c *client) Done(ctx context.Context, workflowID string, state rpc.WorkflowState) error { req := &proto.DoneRequest{ Id: workflowID, State: &proto.WorkflowState{ Started: state.Started, Finished: state.Finished, Error: state.Error, Canceled: state.Canceled, }, } _, err := retryRPC(ctx, c, "done", func() (*proto.Empty, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.Done(ctx, req) return r, classifyRPCErr(ctx, err) }) return err } // Extend extends the workflow deadline. func (c *client) Extend(ctx context.Context, workflowID string) error { req := &proto.ExtendRequest{Id: workflowID} _, err := retryRPC(ctx, c, "extend", func() (*proto.Empty, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.Extend(ctx, req) return r, classifyRPCErr(ctx, err) }) return err } // Update let agent updates the step state at the server. func (c *client) Update(ctx context.Context, workflowID string, state rpc.StepState) error { req := &proto.UpdateRequest{ Id: workflowID, State: &proto.StepState{ StepUuid: state.StepUUID, Started: state.Started, Finished: state.Finished, Exited: state.Exited, ExitCode: int32(state.ExitCode), Error: state.Error, Canceled: state.Canceled, Skipped: state.Skipped, }, } _, err := retryRPC(ctx, c, "update", func() (*proto.Empty, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.Update(ctx, req) return r, classifyRPCErr(ctx, err) }) return err } // EnqueueLog queues the log entry to be written in a batch later. func (c *client) EnqueueLog(logEntry *rpc.LogEntry) { c.logs <- &proto.LogEntry{ StepUuid: logEntry.StepUUID, Data: logEntry.Data, Line: int32(logEntry.Line), Time: logEntry.Time, Type: int32(logEntry.Type), } } func (c *client) processLogs(ctx context.Context) { var entries []*proto.LogEntry var bytes int send := func() { if len(entries) == 0 { return } log.Debug(). Int("entries", len(entries)). Int("bytes", bytes). Msg("log drain: sending queued logs") if err := c.sendLogs(ctx, entries); err != nil { log.Error().Err(err).Msg("log drain: could not send logs to server") } // even if send failed, we don't have infinite memory; retry has already been used entries = entries[:0] bytes = 0 } for { select { case <-ctx.Done(): return case entry, ok := <-c.logs: if !ok { log.Info().Msg("log drain: channel closed") send() return } entries = append(entries, entry) bytes += grpc_proto.Size(entry) if bytes >= maxLogBatchSize { send() } case <-time.After(maxLogFlushPeriod): send() } } } func (c *client) sendLogs(ctx context.Context, entries []*proto.LogEntry) error { req := &proto.LogRequest{LogEntries: entries} // sendLogs intentionally does not gate on IsConnected — the original code // didn't either. backoff.Retry will keep trying through transient transport // errors until MaxElapsedTime elapses. _, err := retryRPC(ctx, c, "log", func() (*proto.Empty, error) { r, err := c.client.Log(ctx, req) return r, classifyRPCErr(ctx, err) }) return err } func (c *client) RegisterAgent(ctx context.Context, info rpc.AgentInfo) (int64, error) { req := &proto.RegisterAgentRequest{ Info: &proto.AgentInfo{ Platform: info.Platform, Backend: info.Backend, Version: info.Version, Capacity: int32(info.Capacity), CustomLabels: info.CustomLabels, }, } res, err := c.client.RegisterAgent(ctx, req) return res.GetAgentId(), err } func (c *client) UnregisterAgent(ctx context.Context) error { _, err := c.client.UnregisterAgent(ctx, &proto.Empty{}) return err } func (c *client) ReportHealth(ctx context.Context) error { req := &proto.ReportHealthRequest{Status: "I am alive!"} _, err := retryRPC(ctx, c, "report_health", func() (*proto.Empty, error) { if !c.IsConnected() { return nil, errNotConnected } r, err := c.client.ReportHealth(ctx, req) return r, classifyRPCErr(ctx, err) }) return err } ================================================ FILE: agent/runner.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agent import ( "context" "errors" "fmt" "runtime" "time" "github.com/rs/zerolog/log" "google.golang.org/grpc/metadata" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" pipeline_runtime "go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const shutdownTimeout = time.Second * 5 type Runner struct { client rpc.Peer filter rpc.Filter hostname string counter *State backend backend_types.Backend } func NewRunner(workEngine rpc.Peer, f rpc.Filter, h string, state *State, backend backend_types.Backend) Runner { return Runner{ client: workEngine, filter: f, hostname: h, counter: state, backend: backend, } } func GetShutdownContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), shutdownTimeout) } // TODO: refactor this big function into subfunctions in it's own subpackage // Run executes a workflow using a backend, tracks its state and reports the state back to the server. func (r *Runner) Run(runnerCtx context.Context) error { log.Debug().Msg("request next execution") // Preserve metadata AND cancellation from runnerCtx. meta, _ := metadata.FromOutgoingContext(runnerCtx) ctxMeta := metadata.NewOutgoingContext(runnerCtx, meta) // Fetch next workflow from the queue workflow, err := r.client.Next(runnerCtx, r.filter) if err != nil { return err } if workflow == nil { return nil } // Compute workflow timeout timeout := time.Hour if minutes := workflow.Timeout; minutes != 0 { timeout = time.Duration(minutes) * time.Minute } repoName := extractRepositoryName(workflow.Config) // hack pipelineNumber := extractPipelineNumber(workflow.Config) // hack // Track workflow execution in runner state r.counter.Add(workflow.ID, timeout, repoName, pipelineNumber) defer r.counter.Done(workflow.ID) logger := log.With(). Str("repo", repoName). Str("pipeline", pipelineNumber). Str("workflow_id", workflow.ID). Logger() logger.Debug().Msg("received execution") // Workflow execution context. // This context is the SINGLE source of truth for cancellation. workflowCtx, _ := context.WithTimeout(ctxMeta, timeout) //nolint:govet workflowCtx, cancelWorkflowCtx := context.WithCancelCause(workflowCtx) defer cancelWorkflowCtx(nil) // Add sigterm support for internal context. // Required to be able to terminate the running workflow by external signals. workflowCtx = utils.WithContextSigtermCallback(workflowCtx, func() { logger.Error().Msg("received sigterm termination signal") // WithContextSigtermCallback would cancel the context too, but we want our own custom error cancelWorkflowCtx(pipeline_errors.ErrCancel) }) // Listen for remote cancel events (UI / API). // When canceled, we MUST cancel the workflow context // so that workflow execution stops immediately. go func() { logger.Debug().Msg("start listening for server side cancel signal") if canceled, err := r.client.Wait(workflowCtx, workflow.ID); err != nil { logger.Error().Err(err).Msg("server returned unexpected err while waiting for workflow to finish run") cancelWorkflowCtx(err) } else { if canceled { logger.Debug().Err(err).Msg("server side cancel signal received") cancelWorkflowCtx(pipeline_errors.ErrCancel) } // Wait returned without error, meaning the workflow finished normally logger.Debug().Msg("cancel listener exited normally") } }() // Periodically extend the workflow lease while running go func() { for { select { case <-workflowCtx.Done(): logger.Debug().Msg("workflow context done") return case <-time.After(constant.TaskTimeout / 3): logger.Debug().Msg("renewing workflow lease") if err := r.client.Extend(workflowCtx, workflow.ID); err != nil { logger.Error().Err(err).Msg("failed to extend workflow lease") } } } }() state := rpc.WorkflowState{ Started: time.Now().Unix(), } if err := r.client.Init(runnerCtx, workflow.ID, state); err != nil { logger.Error().Err(err).Msg("signaling workflow initialization to server failed") // We have an error, maybe the server is currently unreachable or other server-side errors occurred. // So let's clean up and end this not yet started workflow run. cancelWorkflowCtx(err) return err } // Enrich workflow env with agent info // TODO: find better way to track this state for _, stage := range workflow.Config.Stages { for _, step := range stage.Steps { step.Environment["CI_MACHINE"] = r.hostname step.Environment["CI_SYSTEM_PLATFORM"] = runtime.GOOS + "/" + runtime.GOARCH } } // Run pipeline err = pipeline_runtime.New( workflow.Config, r.backend, pipeline_runtime.WithContext(workflowCtx), pipeline_runtime.WithTaskUUID(fmt.Sprint(workflow.ID)), pipeline_runtime.WithLogger(r.createLogger(logger, workflow)), pipeline_runtime.WithTracer(r.createTracer(ctxMeta, logger, workflow)), pipeline_runtime.WithDescription(map[string]string{ "workflow_id": workflow.ID, "repo": repoName, "pipeline_number": pipelineNumber, }), ).Run(runnerCtx) state.Finished = time.Now().Unix() if err != nil { state.Error = err.Error() if errors.Is(err, pipeline_errors.ErrCancel) { state.Canceled = true // cleanup joined error messages state.Error = pipeline_errors.ErrCancel.Error() } } logger.Debug(). Str("error", state.Error). Bool("canceled", state.Canceled). Msg("workflow finished") // Update workflow state doneCtx := runnerCtx //nolint:contextcheck if doneCtx.Err() != nil { shutdownCtx, shutdownCtxCancel := GetShutdownContext() defer shutdownCtxCancel() doneCtx = shutdownCtx } if err := r.client.Done(doneCtx, workflow.ID, state); err != nil { logger.Error().Err(err).Msg("failed to update workflow status") } else { logger.Debug().Msg("signaling workflow stopped done") } return nil } func extractRepositoryName(config *backend_types.Config) string { return config.Stages[0].Steps[0].Environment["CI_REPO"] } func extractPipelineNumber(config *backend_types.Config) string { return config.Stages[0].Steps[0].Environment["CI_PIPELINE_NUMBER"] } ================================================ FILE: agent/state.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agent import ( "encoding/json" "io" "sync" "time" ) type State struct { sync.Mutex `json:"-"` Polling int `json:"polling_count"` Running int `json:"running_count"` Metadata map[string]Info `json:"running"` } type Info struct { ID string `json:"id"` Repo string `json:"repository"` Pipeline string `json:"pipeline_number"` Started time.Time `json:"pipeline_started"` Timeout time.Duration `json:"pipeline_timeout"` } func (s *State) Add(id string, timeout time.Duration, repo, pipeline string) { s.Lock() s.Polling-- s.Running++ s.Metadata[id] = Info{ ID: id, Repo: repo, Pipeline: pipeline, Timeout: timeout, Started: time.Now().UTC(), } s.Unlock() } func (s *State) Done(id string) { s.Lock() s.Polling++ s.Running-- delete(s.Metadata, id) s.Unlock() } func (s *State) Healthy() bool { s.Lock() defer s.Unlock() now := time.Now() buf := time.Hour // 1 hour buffer for _, item := range s.Metadata { if now.After(item.Started.Add(item.Timeout).Add(buf)) { return false } } return true } func (s *State) WriteTo(w io.Writer) (int64, error) { s.Lock() out, err := json.Marshal(s) s.Unlock() if err != nil { return 0, err } ret, err := w.Write(out) return int64(ret), err } ================================================ FILE: agent/tracer.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package agent import ( "context" "errors" "time" "github.com/rs/zerolog" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing" "go.woodpecker-ci.org/woodpecker/v3/rpc" ) func (r *Runner) createTracer(ctxMeta context.Context, logger zerolog.Logger, workflow *rpc.Workflow) tracing.TraceFunc { return func(state *state.State) error { stepLogger := logger.With(). Str("image", state.CurrStep.Image). Str("workflow_id", workflow.ID). Err(state.CurrStepState.Error). Int("exit_code", state.CurrStepState.ExitCode). Bool("exited", state.CurrStepState.Exited). Logger() stepState := rpc.StepState{ StepUUID: state.CurrStep.UUID, Exited: state.CurrStepState.Exited, ExitCode: state.CurrStepState.ExitCode, Started: state.CurrStepState.Started, Canceled: errors.Is(state.CurrStepState.Error, pipeline_errors.ErrCancel), Skipped: state.CurrStepState.Skipped, } if state.CurrStepState.Error != nil { stepState.Error = state.CurrStepState.Error.Error() } if state.CurrStepState.Exited { stepState.Finished = time.Now().Unix() } stepLogger.Debug().Msg("update step status") defer stepLogger.Debug().Msg("update step status complete") return r.client.Update(ctxMeta, workflow.ID, stepState) } } ================================================ FILE: checkmake.ini ================================================ [maxbodylength] disabled = true ================================================ FILE: cli/README.md ================================================ # Woodpecker CLI Command line client for the Woodpecker continuous integration server. Please see the official documentation at ================================================ FILE: cli/admin/admin.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package admin import ( "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/admin/loglevel" "go.woodpecker-ci.org/woodpecker/v3/cli/admin/org" "go.woodpecker-ci.org/woodpecker/v3/cli/admin/registry" "go.woodpecker-ci.org/woodpecker/v3/cli/admin/secret" "go.woodpecker-ci.org/woodpecker/v3/cli/admin/user" ) // Command exports the admin command set. var Command = &cli.Command{ Name: "admin", Usage: "manage server settings", Commands: []*cli.Command{ loglevel.Command, org.Command, registry.Command, secret.Command, user.Command, }, } ================================================ FILE: cli/admin/loglevel/loglevel.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package loglevel import ( "context" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the log-level command used to change the servers log-level. var Command = &cli.Command{ Name: "log-level", ArgsUsage: "[level]", Usage: "retrieve log level from server, or set it with [level]", Action: logLevel, } func logLevel(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } var ll *woodpecker.LogLevel arg := c.Args().First() if arg != "" { lvl, err := zerolog.ParseLevel(arg) if err != nil { return err } ll, err = client.SetLogLevel(&woodpecker.LogLevel{ Level: lvl.String(), }) if err != nil { return err } } else { ll, err = client.LogLevel() if err != nil { return err } } log.Info().Msgf("log level: %s", ll.Level) return nil } ================================================ FILE: cli/admin/org/org_list.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package org import ( "context" "os" "strings" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var Command = &cli.Command{ Name: "org", Usage: "manage organizations", Commands: []*cli.Command{ orgListCmd, }, } var orgListCmd = &cli.Command{ Name: "ls", Usage: "list organizations", ArgsUsage: "", Action: orgList, Flags: []cli.Flag{ common.FormatFlag(tmplOrgList, true), }, } func orgList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } opt := woodpecker.ListOptions{} list, err := client.OrgList(opt) if err != nil { return err } tmpl, err := template.New("_").Funcs(orgFuncMap).Parse(format) if err != nil { return err } for _, org := range list { if err := tmpl.Execute(os.Stdout, org); err != nil { return err } } return nil } // Template for org list items. var tmplOrgList = "\x1b[33m{{ .Name }} \x1b[0m" + ` Organization ID: {{ .ID }} ` var orgFuncMap = template.FuncMap{ "list": func(s []string) string { return strings.Join(s, ", ") }, } ================================================ FILE: cli/admin/registry/registry.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "github.com/urfave/cli/v3" ) // Command exports the registry command set. var Command = &cli.Command{ Name: "registry", Usage: "manage global registries", Commands: []*cli.Command{ registryCreateCmd, registryDeleteCmd, registryListCmd, registryShowCmd, registryUpdateCmd, }, } ================================================ FILE: cli/admin/registry/registry_add.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryCreateCmd = &cli.Command{ Name: "add", Usage: "add a registry", Action: registryCreate, Flags: []cli.Flag{ &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, &cli.StringFlag{ Name: "username", Usage: "registry username", }, &cli.StringFlag{ Name: "password", Usage: "registry password", }, }, } func registryCreate(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") username = c.String("username") password = c.String("password") ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry := &woodpecker.Registry{ Address: hostname, Username: username, Password: password, } if strings.HasPrefix(registry.Password, "@") { path := strings.TrimPrefix(registry.Password, "@") out, err := os.ReadFile(path) if err != nil { return err } registry.Password = string(out) } _, err = client.GlobalRegistryCreate(registry) return err } ================================================ FILE: cli/admin/registry/registry_list.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryListCmd = &cli.Command{ Name: "ls", Usage: "list registries", Action: registryList, Flags: []cli.Flag{ common.FormatFlag(tmplRegistryList, true), }, } func registryList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } opt := woodpecker.RegistryListOptions{} list, err := client.GlobalRegistryList(opt) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } for _, registry := range list { if err := tmpl.Execute(os.Stdout, registry); err != nil { return err } } return nil } // Template for registry list information. var tmplRegistryList = "\x1b[33m{{ .Address }} \x1b[0m" + ` Username: {{ .Username }} Email: {{ .Email }} ` ================================================ FILE: cli/admin/registry/registry_rm.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var registryDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a registry", Action: registryDelete, Flags: []cli.Flag{ &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, }, } func registryDelete(ctx context.Context, c *cli.Command) error { hostname := c.String("hostname") client, err := internal.NewClient(ctx, c) if err != nil { return err } return client.GlobalRegistryDelete(hostname) } ================================================ FILE: cli/admin/registry/registry_set.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryUpdateCmd = &cli.Command{ Name: "update", Usage: "update a registry", Action: registryUpdate, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, &cli.StringFlag{ Name: "username", Usage: "registry username", }, &cli.StringFlag{ Name: "password", Usage: "registry password", }, }, } func registryUpdate(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") username = c.String("username") password = c.String("password") ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry := &woodpecker.Registry{ Address: hostname, Username: username, Password: password, } if strings.HasPrefix(registry.Password, "@") { path := strings.TrimPrefix(registry.Password, "@") out, err := os.ReadFile(path) if err != nil { return err } registry.Password = string(out) } _, err = client.GlobalRegistryUpdate(registry) return err } ================================================ FILE: cli/admin/registry/registry_show.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var registryShowCmd = &cli.Command{ Name: "show", Usage: "show registry information", Action: registryShow, Flags: []cli.Flag{ &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, common.FormatFlag(tmplRegistryList, true), }, } func registryShow(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") format = c.String("format") + "\n" ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry, err := client.GlobalRegistry(hostname) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, registry) } ================================================ FILE: cli/admin/secret/secret.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "github.com/urfave/cli/v3" ) // Command exports the secret command. var Command = &cli.Command{ Name: "secret", Usage: "manage global secrets", Commands: []*cli.Command{ secretCreateCmd, secretDeleteCmd, secretListCmd, secretShowCmd, secretUpdateCmd, }, } ================================================ FILE: cli/admin/secret/secret_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretCreateCmd = &cli.Command{ Name: "add", Usage: "add a secret", Action: secretCreate, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", Usage: "secret name", }, &cli.StringFlag{ Name: "value", Usage: "secret value", }, &cli.StringSliceFlag{ Name: "event", Usage: "secret limited to these events", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Name: "image", Usage: "secret limited to these images", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func secretCreate(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } secret := &woodpecker.Secret{ Name: strings.ToLower(c.String("name")), Value: c.String("value"), Images: c.StringSlice("image"), Events: c.StringSlice("event"), } if len(secret.Events) == 0 { secret.Events = defaultSecretEvents } if strings.HasPrefix(secret.Value, "@") { path := strings.TrimPrefix(secret.Value, "@") out, err := os.ReadFile(path) if err != nil { return err } secret.Value = string(out) } _, err = client.GlobalSecretCreate(secret) return err } var defaultSecretEvents = []string{ woodpecker.EventPush, woodpecker.EventTag, woodpecker.EventRelease, woodpecker.EventDeploy, } ================================================ FILE: cli/admin/secret/secret_list.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "html/template" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretListCmd = &cli.Command{ Name: "ls", Usage: "list secrets", Action: secretList, Flags: []cli.Flag{ common.FormatFlag(tmplSecretList, true), }, } func secretList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } opt := woodpecker.SecretListOptions{} list, err := client.GlobalSecretList(opt) if err != nil { return err } tmpl, err := template.New("_").Funcs(secretFuncMap).Parse(format) if err != nil { return err } for _, registry := range list { if err := tmpl.Execute(os.Stdout, registry); err != nil { return err } } return nil } // Template for secret list items. var tmplSecretList = "\x1b[33m{{ .Name }} \x1b[0m" + ` Events: {{ list .Events }} {{- if .Images }} Images: {{ list .Images }} {{- else }} Images: {{- end }} ` var secretFuncMap = template.FuncMap{ "list": func(s []string) string { return strings.Join(s, ", ") }, } ================================================ FILE: cli/admin/secret/secret_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var secretDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a secret", Action: secretDelete, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", Usage: "secret name", }, }, } func secretDelete(ctx context.Context, c *cli.Command) error { secretName := c.String("name") client, err := internal.NewClient(ctx, c) if err != nil { return err } return client.GlobalSecretDelete(secretName) } ================================================ FILE: cli/admin/secret/secret_set.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretUpdateCmd = &cli.Command{ Name: "update", Usage: "update a secret", Action: secretUpdate, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", Usage: "secret name", }, &cli.StringFlag{ Name: "value", Usage: "secret value", }, &cli.StringSliceFlag{ Name: "event", Usage: "secret limited to these events", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Name: "image", Usage: "secret limited to these images", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func secretUpdate(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } secret := &woodpecker.Secret{ Name: strings.ToLower(c.String("name")), Value: c.String("value"), Images: c.StringSlice("image"), Events: c.StringSlice("event"), } if strings.HasPrefix(secret.Value, "@") { path := strings.TrimPrefix(secret.Value, "@") out, err := os.ReadFile(path) if err != nil { return err } secret.Value = string(out) } _, err = client.GlobalSecretUpdate(secret) return err } ================================================ FILE: cli/admin/secret/secret_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "fmt" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var secretShowCmd = &cli.Command{ Name: "show", Usage: "show secret information", Action: secretShow, Flags: []cli.Flag{ &cli.StringFlag{ Name: "name", Usage: "secret name", }, common.FormatFlag(tmplSecretList, true), }, } func secretShow(ctx context.Context, c *cli.Command) error { var ( secretName = c.String("name") format = c.String("format") + "\n" ) if secretName == "" { return fmt.Errorf("secret name is missing") } client, err := internal.NewClient(ctx, c) if err != nil { return err } secret, err := client.GlobalSecret(secretName) if err != nil { return err } tmpl, err := template.New("_").Funcs(secretFuncMap).Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, secret) } ================================================ FILE: cli/admin/user/user.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package user import ( "github.com/urfave/cli/v3" ) // Command exports the user command set. var Command = &cli.Command{ Name: "user", Usage: "manage users", Commands: []*cli.Command{ userAddCmd, userListCmd, userRemoveCmd, userShowCmd, }, } ================================================ FILE: cli/admin/user/user_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package user import ( "context" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var userAddCmd = &cli.Command{ Name: "add", Usage: "add a user", ArgsUsage: "", Action: userAdd, } func userAdd(ctx context.Context, c *cli.Command) error { login := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } user, err := client.UserPost(&woodpecker.User{Login: login}) if err != nil { return err } fmt.Printf("Successfully added user %s\n", user.Login) return nil } ================================================ FILE: cli/admin/user/user_list.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package user import ( "context" "os" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var userListCmd = &cli.Command{ Name: "ls", Usage: "list all users", ArgsUsage: " ", Action: userList, Flags: []cli.Flag{common.FormatFlag(tmplUserList, false)}, } func userList(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } opt := woodpecker.UserListOptions{} users, err := client.UserList(opt) if err != nil || len(users) == 0 { return err } tmpl, err := template.New("_").Parse(c.String("format") + "\n") if err != nil { return err } for _, user := range users { if err := tmpl.Execute(os.Stdout, user); err != nil { return err } } return nil } // Template for user list items. var tmplUserList = `{{ .Login }}` ================================================ FILE: cli/admin/user/user_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package user import ( "context" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var userRemoveCmd = &cli.Command{ Name: "rm", Usage: "remove a user", ArgsUsage: "", Action: userRemove, } func userRemove(ctx context.Context, c *cli.Command) error { login := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } if err := client.UserDel(login); err != nil { return err } fmt.Printf("Successfully removed user %s\n", login) return nil } ================================================ FILE: cli/admin/user/user_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package user import ( "context" "fmt" "os" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var userShowCmd = &cli.Command{ Name: "show", Usage: "show user information", ArgsUsage: "", Action: userShow, Flags: []cli.Flag{common.FormatFlag(tmplUserInfo, false)}, } func userShow(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } login := c.Args().First() if len(login) == 0 { return fmt.Errorf("missing or invalid user login") } user, err := client.User(login) if err != nil { return err } tmpl, err := template.New("_").Parse(c.String("format") + "\n") if err != nil { return err } return tmpl.Execute(os.Stdout, user) } // Template for user information. var tmplUserInfo = `User: {{ .Login }} Email: {{ .Email }}` ================================================ FILE: cli/common/flags.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" ) var GlobalFlags = append([]cli.Flag{ &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_CONFIG"), Name: "config", Aliases: []string{"c"}, Usage: "path to config file", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SERVER"), Name: "server", Aliases: []string{"s"}, Usage: "server address", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_TOKEN"), Name: "token", Aliases: []string{"t"}, Usage: "server auth token", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DISABLE_UPDATE_CHECK"), Name: "disable-update-check", Usage: "disable update check", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_SKIP_VERIFY"), Name: "skip-verify", Usage: "skip ssl verification", }, &cli.StringFlag{ Sources: cli.EnvVars("SOCKS_PROXY"), Name: "socks-proxy", Usage: "socks proxy address", }, &cli.BoolFlag{ Sources: cli.EnvVars("SOCKS_PROXY_OFF"), Name: "socks-proxy-off", Usage: "socks proxy ignored", }, }, logger.GlobalLoggerFlags...) // FormatFlag return format flag with value set based on template // if hidden value is set, flag will be hidden. func FormatFlag(tmpl string, deprecated bool, hidden ...bool) *cli.StringFlag { usage := "format output" if deprecated { usage = fmt.Sprintf("%s (deprecated)", usage) } return &cli.StringFlag{ Name: "format", Usage: usage, Value: tmpl, Hidden: len(hidden) != 0, } } // OutputFlags returns a slice of cli.Flag containing output format options. func OutputFlags(def string) []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "output", Usage: "output format", Value: def, }, &cli.BoolFlag{ Name: "output-no-headers", Usage: "don't print headers", }, } } var RepoFlag = &cli.StringFlag{ Name: "repository", Aliases: []string{"repo"}, Usage: "repository id or full name (e.g. 134 or octocat/hello-world)", } var OrgFlag = &cli.StringFlag{ Name: "organization", Aliases: []string{"org"}, Usage: "organization id or full name (e.g. 123 or octocat)", } ================================================ FILE: cli/common/hooks.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "context" "errors" "time" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal/config" "go.woodpecker-ci.org/woodpecker/v3/cli/update" ) var ( waitForUpdateCheck context.Context cancelWaitForUpdate context.CancelCauseFunc ) func Before(ctx context.Context, c *cli.Command) (context.Context, error) { if err := setupGlobalLogger(ctx, c); err != nil { return ctx, err } go func(context.Context) { if c.Bool("disable-update-check") { return } // Don't check for updates when the update command is executed if firstArg := c.Args().First(); firstArg == "update" { return } waitForUpdateCheck, cancelWaitForUpdate = context.WithCancelCause(context.Background()) defer cancelWaitForUpdate(errors.New("update check finished")) log.Debug().Msg("checking for updates ...") newVersion, err := update.CheckForUpdate(waitForUpdateCheck, false) //nolint:contextcheck if err != nil { log.Error().Err(err).Msgf("failed to check for updates") return } if newVersion != nil { log.Warn().Msgf("new version of woodpecker-cli is available: %s, update with: %s update", newVersion.Version, c.Root().Name) } else { log.Debug().Msgf("no update required") } }(ctx) return ctx, config.Load(ctx, c) } func After(_ context.Context, _ *cli.Command) error { if waitForUpdateCheck != nil { select { case <-waitForUpdateCheck.Done(): // When the actual command already finished, we still wait 500ms for the update check to finish case <-time.After(time.Millisecond * 500): log.Debug().Msg("update check stopped due to timeout") cancelWaitForUpdate(errors.New("update check timeout")) } } return nil } ================================================ FILE: cli/common/pipeline.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "context" "fmt" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) func DetectPipelineConfig() (isDir bool, config string, _ error) { for _, config := range constant.DefaultConfigOrder { shouldBeDir := strings.HasSuffix(config, "/") config = strings.TrimSuffix(config, "/") if fi, err := os.Stat(config); err == nil && shouldBeDir == fi.IsDir() { return fi.IsDir(), config, nil } } return false, "", fmt.Errorf("could not detect pipeline config") } func RunPipelineFunc(ctx context.Context, c *cli.Command, fileFunc, dirFunc func(context.Context, *cli.Command, string) error) error { if c.Args().Len() == 0 { isDir, path, err := DetectPipelineConfig() if err != nil { return err } if isDir { return dirFunc(ctx, c, path) } return fileFunc(ctx, c, path) } multiArgs := c.Args().Len() > 1 for _, arg := range c.Args().Slice() { fi, err := os.Stat(arg) if err != nil { return err } if multiArgs { fmt.Println("#", fi.Name()) } if fi.IsDir() { if err := dirFunc(ctx, c, arg); err != nil { return err } } else { if err := fileFunc(ctx, c, arg); err != nil { return err } } if multiArgs { fmt.Println("") } } return nil } ================================================ FILE: cli/common/zerologger.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" ) func setupGlobalLogger(ctx context.Context, c *cli.Command) error { return logger.SetupGlobalLogger(ctx, c, false) } ================================================ FILE: cli/context/context.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package context import ( "context" "fmt" "os" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal/config" "go.woodpecker-ci.org/woodpecker/v3/cli/output" ) // Command exports the context command set. var Command = &cli.Command{ Name: "context", Aliases: []string{"ctx"}, Usage: "manage contexts", Description: "Contexts can be used to manage users on one or multiple servers.\nTo create a new context run the setup command", Commands: []*cli.Command{ listCommand, useCommand, deleteCommand, renameCommand, }, } var listCommand = &cli.Command{ Name: "list", Aliases: []string{"ls"}, Usage: "list all contexts", Flags: append(common.OutputFlags("table"), []cli.Flag{ &cli.BoolFlag{ Name: "output-no-headers", Usage: "do not print headers in output", }, }...), Action: listContexts, } var useCommand = &cli.Command{ Name: "use", Usage: "set the current context", ArgsUsage: "", Action: useContext, } var deleteCommand = &cli.Command{ Name: "delete", Aliases: []string{"rm"}, Usage: "delete a context", ArgsUsage: "", Action: deleteContext, } var renameCommand = &cli.Command{ Name: "rename", Usage: "rename a context", ArgsUsage: " ", Action: renameContext, } func listContexts(_ context.Context, c *cli.Command) error { contexts, err := config.LoadContexts() if err != nil { return err } if len(contexts.Contexts) == 0 { fmt.Println("No contexts found. Run 'woodpecker-cli setup' to create one.") return nil } _, outOpt := output.ParseOutputOptions(c.String("output")) out := os.Stdout noHeader := c.Bool("output-no-headers") table := output.NewTable(out) // Add custom field mapping table.AddFieldFn("Name", func(obj any) string { c, ok := obj.(config.Context) if !ok { return "???" } if contexts.CurrentContext == c.Name { return c.Name + " *" } return c.Name }) table.AddFieldAlias("ServerURL", "Server URL") table.AddFieldAlias("LogLevel", "Log Level") table.AddFieldAlias("Name", "Name (selected)") cols := []string{"Name (selected)", "Server URL"} if len(outOpt) > 0 { cols = outOpt } if !noHeader { table.WriteHeader(cols) } for _, c := range contexts.Contexts { if err := table.Write(cols, c); err != nil { return err } } return table.Flush() } func useContext(_ context.Context, c *cli.Command) error { contextName := c.Args().First() if contextName == "" { return fmt.Errorf("context name is required") } err := config.SetCurrentContext(contextName) if err != nil { return err } log.Info().Msgf("Switched to context '%s'", contextName) return nil } func deleteContext(_ context.Context, c *cli.Command) error { contextName := c.Args().First() if contextName == "" { return fmt.Errorf("context name is required") } err := config.DeleteContext(c, contextName) if err != nil { return err } log.Info().Msgf("Context '%s' deleted", contextName) return nil } func renameContext(_ context.Context, c *cli.Command) error { if c.Args().Len() < 2 { //nolint:mnd // min args return fmt.Errorf("both old name and new name are required") } oldName := c.Args().Get(0) newName := c.Args().Get(1) err := config.RenameContext(oldName, newName) if err != nil { return err } log.Info().Msgf("Context renamed from '%s' to '%s'", oldName, newName) return nil } ================================================ FILE: cli/exec/dummy.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package exec import "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" func init() { //nolint:gochecknoinits backends = append(backends, dummy.New()) } ================================================ FILE: cli/exec/exec.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package exec import ( "context" "fmt" "io" "maps" "os" "path" "path/filepath" "runtime" "slices" "strings" "codeberg.org/6543/xyaml" "github.com/drone/envsubst" "github.com/oklog/ulid/v2" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.uber.org/multierr" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/lint" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix" "go.woodpecker-ci.org/woodpecker/v3/pipeline/logging" pipeline_runtime "go.woodpecker-ci.org/woodpecker/v3/pipeline/runtime" pipeline_utils "go.woodpecker-ci.org/woodpecker/v3/pipeline/utils" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) // Command exports the exec command. var Command = &cli.Command{ Name: "exec", Usage: "execute a local pipeline", ArgsUsage: "[path/to/.woodpecker.yaml]", Action: run, Flags: slices.Concat(flags, docker.Flags, kubernetes.Flags, local.Flags), } var backends = []backend_types.Backend{ kubernetes.New(), docker.New(), local.New(), } func run(ctx context.Context, c *cli.Command) error { return common.RunPipelineFunc(ctx, c, execFile, execDir) } func execDir(ctx context.Context, c *cli.Command, dir string) error { // TODO: respect pipeline dependency repoPath := c.String("repo-path") if repoPath != "" { repoPath, _ = filepath.Abs(repoPath) } else { repoPath, _ = filepath.Abs(filepath.Dir(dir)) } if runtime.GOOS == "windows" && c.String("backend-engine") != "local" { repoPath = convertPathForWindows(repoPath) } var execErr error // TODO: respect depends_on and do parallel runs with output to multiple _windows_ e.g. tmux like walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error { if e != nil { return e } // check if it is a regular file (not dir) if info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), ".yaml") || strings.HasSuffix(info.Name(), ".yml")) { fmt.Println("#", info.Name()) err := runExec(ctx, c, path, repoPath, false) if err != nil { fmt.Print(err) execErr = multierr.Append(execErr, err) } fmt.Println("") return nil } return nil }) if walkErr != nil { return walkErr } return execErr } func execFile(ctx context.Context, c *cli.Command, file string) error { repoPath := c.String("repo-path") if repoPath != "" { repoPath, _ = filepath.Abs(repoPath) } else { repoPath, _ = filepath.Abs(filepath.Dir(file)) } if runtime.GOOS == "windows" && c.String("backend-engine") != "local" { repoPath = convertPathForWindows(repoPath) } return runExec(ctx, c, file, repoPath, true) } func runExec(ctx context.Context, c *cli.Command, file, repoPath string, singleExec bool) error { dat, err := os.ReadFile(file) if err != nil { return err } // if we use the local backend we should signal to run at $repoPath if c.String("backend-engine") == "local" { local.CLIWorkaroundExecAtDir = repoPath } axes, err := matrix.ParseString(string(dat)) if err != nil { return fmt.Errorf("parse matrix fail") } if len(axes) == 0 { axes = append(axes, matrix.Axis{}) } for _, axis := range axes { err := execWithAxis(ctx, c, file, repoPath, axis, singleExec) if err != nil { return err } } return nil } func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, axis matrix.Axis, singleExec bool) error { metadataWorkflow := &metadata.Workflow{} if !singleExec { // TODO: proper try to use the engine to generate the same metadata for workflows // https://github.com/woodpecker-ci/woodpecker/pull/3967 metadataWorkflow.Name = strings.TrimSuffix(strings.TrimSuffix(file, ".yaml"), ".yml") } metadata, err := metadataFromContext(ctx, c, axis, metadataWorkflow) if err != nil { return fmt.Errorf("could not create metadata: %w", err) } else if metadata == nil { return fmt.Errorf("metadata is nil") } environ := metadata.Environ() maps.Copy(environ, metadata.Workflow.Matrix) var secrets []compiler.Secret for key, val := range c.StringMap("secrets") { secrets = append(secrets, compiler.Secret{ Name: key, Value: val, }) } if secretsFile := c.String("secrets-file"); secretsFile != "" { fileContent, err := os.ReadFile(secretsFile) if err != nil { return err } var m map[string]string err = xyaml.Unmarshal(fileContent, &m) if err != nil { return err } for key, val := range m { secrets = append(secrets, compiler.Secret{ Name: key, Value: val, }) } } pipelineEnv := make(map[string]string) for _, env := range c.StringSlice("env") { before, after, _ := strings.Cut(env, "=") pipelineEnv[before] = after if oldVar, exists := environ[before]; exists { // override existing values, but print a warning log.Warn().Msgf("environment variable '%s' had value '%s', but got overwritten", before, oldVar) } environ[before] = after } tmpl, err := envsubst.ParseFile(file) if err != nil { return err } confStr, err := tmpl.Execute(func(name string) string { return environ[name] }) if err != nil { return err } conf, err := yaml.ParseString(confStr) if err != nil { return err } // emulate server behavior https://github.com/woodpecker-ci/woodpecker/blob/eebaa10d104cbc3fa7ce4c0e344b0b7978405135/server/pipeline/stepbuilder/stepBuilder.go#L289-L295 prefix := "wp_" + ulid.Make().String() // configure volumes for local execution volumes := c.StringSlice("volumes") if c.Bool("local") { var ( workspaceBase = conf.Workspace.Base workspacePath = conf.Workspace.Path ) if workspaceBase == "" { workspaceBase = c.String("workspace-base") } if workspacePath == "" { workspacePath = c.String("workspace-path") } volumes = append(volumes, prefix+"_default:"+workspaceBase) volumes = append(volumes, repoPath+":"+path.Join(workspaceBase, workspacePath)) } privilegedPlugins := c.StringSlice("plugins-privileged") // lint the yaml file err = linter.New( linter.WithTrusted(linter.TrustedConfiguration{ Security: c.Bool("repo-trusted-security"), Network: c.Bool("repo-trusted-network"), Volumes: c.Bool("repo-trusted-volumes"), }), linter.PrivilegedPlugins(privilegedPlugins), linter.WithTrustedClonePlugins(constant.TrustedClonePlugins), ).Lint([]*linter.WorkflowConfig{{ File: path.Base(file), RawConfig: confStr, Workflow: conf, }}) if err != nil { str, err := lint.FormatLintError(file, err, false) fmt.Print(str) if err != nil { return err } } // compiles the yaml file compiled, err := compiler.New( compiler.WithEscalated( privilegedPlugins..., ), compiler.WithVolumes(volumes...), compiler.WithWorkspace( c.String("workspace-base"), c.String("workspace-path"), ), compiler.WithNetworks( c.StringSlice("network")..., ), compiler.WithPrefix(prefix), compiler.WithProxy(compiler.ProxyOptions{ NoProxy: c.String("backend-no-proxy"), HTTPProxy: c.String("backend-http-proxy"), HTTPSProxy: c.String("backend-https-proxy"), }), compiler.WithLocal( c.Bool("local"), ), compiler.WithNetrc( c.String("netrc-username"), c.String("netrc-password"), c.String("netrc-machine"), ), compiler.WithMetadata(*metadata), compiler.WithSecret(secrets...), compiler.WithEnviron(pipelineEnv), ).Compile(conf) if err != nil { return err } backendCtx := context.WithValue(ctx, backend_types.CliCommand, c) backendEngine, err := backend.FindBackend(backendCtx, backends, c.String("backend-engine")) if err != nil { return err } if _, err = backendEngine.Load(backendCtx); err != nil { return err } pipelineCtx, cancel := context.WithTimeout(context.Background(), c.Duration("timeout")) defer cancel() pipelineCtx = utils.WithContextSigtermCallback(pipelineCtx, func() { fmt.Printf("ctrl+c received, terminating current pipeline '%s'\n", confStr) }) return pipeline_runtime.New(compiled, backendEngine, pipeline_runtime.WithContext(pipelineCtx), //nolint:contextcheck pipeline_runtime.WithLogger(defaultLogger), pipeline_runtime.WithDescription(map[string]string{ "CLI": "exec", }), ).Run(ctx) } // convertPathForWindows converts a path to use slash separators // for Windows. If the path is a Windows volume name like C:, it // converts it to an absolute root path starting with slash (e.g. // C: -> /c). Otherwise it just converts backslash separators to // slashes. func convertPathForWindows(path string) string { base := filepath.VolumeName(path) // Check if path is volume name like C: //nolint:mnd if len(base) == 2 { path = strings.TrimPrefix(path, base) base = strings.ToLower(strings.TrimSuffix(base, ":")) return "/" + base + filepath.ToSlash(path) } return filepath.ToSlash(path) } var defaultLogger = logging.Logger(func(step *backend_types.Step, rc io.ReadCloser) error { logWriter := NewLineWriter(step.Name, step.UUID) return pipeline_utils.CopyLineByLine(logWriter, rc, pipeline.MaxLogLineLength) }) ================================================ FILE: cli/exec/flags.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package exec import ( "time" "github.com/urfave/cli/v3" ) var flags = []cli.Flag{ &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_LOCAL"), Name: "local", Usage: "run from local directory", Value: true, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_REPO_PATH"), Name: "repo-path", Usage: "path to local repository", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_METADATA_FILE"), Name: "metadata-file", Usage: "path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags", }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_TIMEOUT"), Name: "timeout", Usage: "pipeline timeout", Value: time.Hour, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_VOLUMES"), Name: "volumes", Usage: "pipeline volumes", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_NETWORKS"), Name: "network", Usage: "external networks", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_PLUGINS_PRIVILEGED"), Name: "plugins-privileged", Usage: "Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND"), Name: "backend-engine", Usage: "backend engine to run pipelines on", Value: "auto-detect", }, &cli.StringMapFlag{ Sources: cli.EnvVars("WOODPECKER_SECRETS"), Name: "secrets", Usage: "map of secrets, ex. 'secret=\"val\",secret2=\"value2\"'", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SECRETS_FILE"), Name: "secrets-file", Usage: "path to yaml file with secrets map", }, // // backend options for pipeline compiler // &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_NO_PROXY", "NO_PROXY", "no_proxy"), Usage: "if set, pass the environment variable down as \"NO_PROXY\" to steps", Name: "backend-no-proxy", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_HTTP_PROXY", "HTTP_PROXY", "http_proxy"), Usage: "if set, pass the environment variable down as \"HTTP_PROXY\" to steps", Name: "backend-http-proxy", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_HTTPS_PROXY", "HTTPS_PROXY", "https_proxy"), Usage: "if set, pass the environment variable down as \"HTTPS_PROXY\" to steps", Name: "backend-https-proxy", }, // // Please note the below flags should match the flags from // pipeline/frontend/metadata.go and should be kept synchronized. // // // workspace default // &cli.StringFlag{ Sources: cli.EnvVars("CI_WORKSPACE_BASE"), Name: "workspace-base", Value: "/woodpecker", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_WORKSPACE_PATH"), Name: "workspace-path", Value: "src", }, // // netrc parameters // &cli.StringFlag{ Sources: cli.EnvVars("CI_NETRC_USERNAME"), Name: "netrc-username", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_NETRC_PASSWORD"), Name: "netrc-password", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_NETRC_MACHINE"), Name: "netrc-machine", }, // // metadata parameters // &cli.StringFlag{ Sources: cli.EnvVars("CI_SYSTEM_PLATFORM"), Name: "system-platform", Usage: "Set the metadata environment variable \"CI_SYSTEM_PLATFORM\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_SYSTEM_HOST"), Name: "system-host", Usage: "Set the metadata environment variable \"CI_SYSTEM_HOST\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_SYSTEM_NAME"), Name: "system-name", Usage: "Set the metadata environment variable \"CI_SYSTEM_NAME\".", Value: "woodpecker", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_SYSTEM_URL"), Name: "system-url", Usage: "Set the metadata environment variable \"CI_SYSTEM_URL\".", Value: "https://github.com/woodpecker-ci/woodpecker", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO"), Name: "repo", Usage: "Set the full name to derive metadata environment variables \"CI_REPO\", \"CI_REPO_NAME\" and \"CI_REPO_OWNER\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO_REMOTE_ID"), Name: "repo-remote-id", Usage: "Set the metadata environment variable \"CI_REPO_REMOTE_ID\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO_URL"), Name: "repo-url", Usage: "Set the metadata environment variable \"CI_REPO_URL\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO_DEFAULT_BRANCH"), Name: "repo-default-branch", Usage: "Set the metadata environment variable \"CI_REPO_DEFAULT_BRANCH\".", Value: "main", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO_CLONE_URL"), Name: "repo-clone-url", Usage: "Set the metadata environment variable \"CI_REPO_CLONE_URL\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO_CLONE_SSH_URL"), Name: "repo-clone-ssh-url", Usage: "Set the metadata environment variable \"CI_REPO_CLONE_SSH_URL\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_REPO_PRIVATE"), Name: "repo-private", Usage: "Set the metadata environment variable \"CI_REPO_PRIVATE\".", }, &cli.BoolFlag{ Sources: cli.EnvVars("CI_REPO_TRUSTED_NETWORK"), Name: "repo-trusted-network", Usage: "Set the metadata environment variable \"CI_REPO_TRUSTED_NETWORK\".", }, &cli.BoolFlag{ Sources: cli.EnvVars("CI_REPO_TRUSTED_VOLUMES"), Name: "repo-trusted-volumes", Usage: "Set the metadata environment variable \"CI_REPO_TRUSTED_VOLUMES\".", }, &cli.BoolFlag{ Sources: cli.EnvVars("CI_REPO_TRUSTED_SECURITY"), Name: "repo-trusted-security", Usage: "Set the metadata environment variable \"CI_REPO_TRUSTED_SECURITY\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PIPELINE_NUMBER"), Name: "pipeline-number", Usage: "Set the metadata environment variable \"CI_PIPELINE_NUMBER\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PIPELINE_PARENT"), Name: "pipeline-parent", Usage: "Set the metadata environment variable \"CI_PIPELINE_PARENT\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PIPELINE_CREATED"), Name: "pipeline-created", Usage: "Set the metadata environment variable \"CI_PIPELINE_CREATED\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PIPELINE_STARTED"), Name: "pipeline-started", Usage: "Set the metadata environment variable \"CI_PIPELINE_STARTED\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PIPELINE_EVENT"), Name: "pipeline-event", Usage: "Set the metadata environment variable \"CI_PIPELINE_EVENT\".", Value: "manual", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PIPELINE_FORGE_URL"), Name: "pipeline-url", Usage: "Set the metadata environment variable \"CI_PIPELINE_FORGE_URL\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PIPELINE_DEPLOY_TARGET"), Name: "pipeline-deploy-to", Usage: "Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TARGET\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PIPELINE_DEPLOY_TASK"), Name: "pipeline-deploy-task", Usage: "Set the metadata environment variable \"CI_PIPELINE_DEPLOY_TASK\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PIPELINE_FILES"), Usage: "Set the metadata environment variable \"CI_PIPELINE_FILES\", either json formatted list of strings, or comma separated string list.", Name: "pipeline-changed-files", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_SHA"), Name: "commit-sha", Usage: "Set the metadata environment variable \"CI_COMMIT_SHA\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_REF"), Name: "commit-ref", Usage: "Set the metadata environment variable \"CI_COMMIT_REF\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_REFSPEC"), Name: "commit-refspec", Usage: "Set the metadata environment variable \"CI_COMMIT_REFSPEC\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_BRANCH"), Name: "commit-branch", Usage: "Set the metadata environment variable \"CI_COMMIT_BRANCH\".", Value: "main", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_MESSAGE"), Name: "commit-message", Usage: "Set the metadata environment variable \"CI_COMMIT_MESSAGE\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_AUTHOR"), Name: "commit-author-name", Usage: "Set the metadata environment variable \"CI_COMMIT_AUTHOR\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_AUTHOR_AVATAR"), Name: "commit-author-avatar", Usage: "Set the metadata environment variable \"CI_COMMIT_AUTHOR_AVATAR\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_AUTHOR_EMAIL"), Name: "commit-author-email", Usage: "Set the metadata environment variable \"CI_COMMIT_AUTHOR_EMAIL\".", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("CI_COMMIT_PULL_REQUEST_LABELS"), Name: "commit-pull-labels", Usage: "Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_LABELS\".", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("CI_COMMIT_PULL_REQUEST_MILESTONE"), Name: "commit-pull-milestone", Usage: "Set the metadata environment variable \"CI_COMMIT_PULL_REQUEST_MILESTONE\".", }, &cli.BoolFlag{ Sources: cli.EnvVars("CI_COMMIT_PRERELEASE"), Name: "commit-release-is-pre", Usage: "Set the metadata environment variable \"CI_COMMIT_PRERELEASE\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_NUMBER"), Name: "prev-pipeline-number", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_NUMBER\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_CREATED"), Name: "prev-pipeline-created", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_CREATED\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_STARTED"), Name: "prev-pipeline-started", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_STARTED\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_FINISHED"), Name: "prev-pipeline-finished", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_FINISHED\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_STATUS"), Name: "prev-pipeline-status", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_STATUS\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_EVENT"), Name: "prev-pipeline-event", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_EVENT\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_FORGE_URL"), Name: "prev-pipeline-url", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_FORGE_URL\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_DEPLOY_TARGET"), Name: "prev-pipeline-deploy-to", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TARGET\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_PIPELINE_DEPLOY_TASK"), Name: "prev-pipeline-deploy-task", Usage: "Set the metadata environment variable \"CI_PREV_PIPELINE_DEPLOY_TASK\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_SHA"), Name: "prev-commit-sha", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_SHA\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_REF"), Name: "prev-commit-ref", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_REF\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_REFSPEC"), Name: "prev-commit-refspec", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_REFSPEC\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_BRANCH"), Name: "prev-commit-branch", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_BRANCH\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_MESSAGE"), Name: "prev-commit-message", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_MESSAGE\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_AUTHOR"), Name: "prev-commit-author-name", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_AUTHOR_AVATAR"), Name: "prev-commit-author-avatar", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_AVATAR\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_PREV_COMMIT_AUTHOR_EMAIL"), Name: "prev-commit-author-email", Usage: "Set the metadata environment variable \"CI_PREV_COMMIT_AUTHOR_EMAIL\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_WORKFLOW_NAME"), Name: "workflow-name", Usage: "Set the metadata environment variable \"CI_WORKFLOW_NAME\".", }, &cli.Int64Flag{ Sources: cli.EnvVars("CI_WORKFLOW_NUMBER"), Name: "workflow-number", Usage: "Set the metadata environment variable \"CI_WORKFLOW_NUMBER\".", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("CI_ENV"), Name: "env", Usage: "Set the metadata environment variable \"CI_ENV\".", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("CI_FORGE_TYPE"), Name: "forge-type", Usage: "Set the metadata environment variable \"CI_FORGE_TYPE\".", }, &cli.StringFlag{ Sources: cli.EnvVars("CI_FORGE_URL"), Name: "forge-url", Usage: "Set the metadata environment variable \"CI_FORGE_URL\".", }, } ================================================ FILE: cli/exec/line.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package exec import ( "fmt" "io" "os" "time" ) // LineWriter sends logs to the client. type LineWriter struct { stepName string stepUUID string num int startTime time.Time } // NewLineWriter returns a new line reader. func NewLineWriter(stepName, stepUUID string) io.WriteCloser { return &LineWriter{ stepName: stepName, stepUUID: stepUUID, startTime: time.Now().UTC(), } } func (w *LineWriter) Write(p []byte) (n int, err error) { fmt.Fprintf(os.Stderr, "[%s:L%d:%ds] %s", w.stepName, w.num, int64(time.Since(w.startTime).Seconds()), p) w.num++ return len(p), nil } func (w *LineWriter) Close() error { return nil } ================================================ FILE: cli/exec/metadata.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package exec import ( "context" "encoding/json" "fmt" "os" "runtime" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix" "go.woodpecker-ci.org/woodpecker/v3/version" ) // return the metadata from the cli context. func metadataFromContext(_ context.Context, c *cli.Command, axis matrix.Axis, w *metadata.Workflow) (*metadata.Metadata, error) { m := &metadata.Metadata{} if w != nil { m.Workflow = *w } if c.IsSet("metadata-file") { metadataFile, err := os.Open(c.String("metadata-file")) if err != nil { return nil, err } defer metadataFile.Close() if err := json.NewDecoder(metadataFile).Decode(m); err != nil { return nil, err } } platform := c.String("system-platform") if platform == "" { platform = runtime.GOOS + "/" + runtime.GOARCH } metadataFileAndOverrideOrDefault(c, "repo-name", func(fullRepoName string) { if idx := strings.LastIndex(fullRepoName, "/"); idx != -1 { m.Repo.Owner = fullRepoName[:idx] m.Repo.Name = fullRepoName[idx+1:] } }, c.String) var err error metadataFileAndOverrideOrDefault(c, "pipeline-changed-files", func(changedFilesRaw string) { var changedFiles []string if len(changedFilesRaw) != 0 && changedFilesRaw[0] == '[' { if jsonErr := json.Unmarshal([]byte(changedFilesRaw), &changedFiles); jsonErr != nil { err = fmt.Errorf("pipeline-changed-files detected json but could not parse it: %w", jsonErr) } } else { for _, file := range strings.Split(changedFilesRaw, ",") { changedFiles = append(changedFiles, strings.TrimSpace(file)) } } m.Curr.Commit.ChangedFiles = changedFiles }, c.String) if err != nil { return nil, err } // Repo metadataFileAndOverrideOrDefault(c, "repo-remote-id", func(s string) { m.Repo.RemoteID = s }, c.String) metadataFileAndOverrideOrDefault(c, "repo-url", func(s string) { m.Repo.ForgeURL = s }, c.String) metadataFileAndOverrideOrDefault(c, "repo-default-branch", func(s string) { m.Repo.Branch = s }, c.String) metadataFileAndOverrideOrDefault(c, "repo-clone-url", func(s string) { m.Repo.CloneURL = s }, c.String) metadataFileAndOverrideOrDefault(c, "repo-clone-ssh-url", func(s string) { m.Repo.CloneSSHURL = s }, c.String) metadataFileAndOverrideOrDefault(c, "repo-private", func(b bool) { m.Repo.Private = b }, c.Bool) metadataFileAndOverrideOrDefault(c, "repo-trusted-network", func(b bool) { m.Repo.Trusted.Network = b }, c.Bool) metadataFileAndOverrideOrDefault(c, "repo-trusted-security", func(b bool) { m.Repo.Trusted.Security = b }, c.Bool) metadataFileAndOverrideOrDefault(c, "repo-trusted-volumes", func(b bool) { m.Repo.Trusted.Volumes = b }, c.Bool) // Current Pipeline metadataFileAndOverrideOrDefault(c, "pipeline-number", func(i int64) { m.Curr.Number = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "pipeline-parent", func(i int64) { m.Curr.Parent = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "pipeline-created", func(i int64) { m.Curr.Created = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "pipeline-started", func(i int64) { m.Curr.Started = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "pipeline-finished", func(i int64) { m.Curr.Finished = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "pipeline-status", func(s string) { m.Curr.Status = s }, c.String) metadataFileAndOverrideOrDefault(c, "pipeline-event", func(s string) { m.Curr.Event = metadata.Event(s) }, c.String) metadataFileAndOverrideOrDefault(c, "pipeline-url", func(s string) { m.Curr.ForgeURL = s }, c.String) metadataFileAndOverrideOrDefault(c, "pipeline-deploy-to", func(s string) { m.Curr.DeployTo = s }, c.String) metadataFileAndOverrideOrDefault(c, "pipeline-deploy-task", func(s string) { m.Curr.DeployTask = s }, c.String) // Current Pipeline Commit metadataFileAndOverrideOrDefault(c, "commit-sha", func(s string) { m.Curr.Commit.Sha = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-ref", func(s string) { m.Curr.Commit.Ref = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-refspec", func(s string) { m.Curr.Commit.Refspec = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-branch", func(s string) { m.Curr.Commit.Branch = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-message", func(s string) { m.Curr.Commit.Message = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-author-name", func(s string) { m.Curr.Commit.Author.Name = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-author-email", func(s string) { m.Curr.Commit.Author.Email = s }, c.String) // TODO remove in next major metadataFileAndOverrideOrDefault(c, "commit-author-avatar", func(s string) { m.Curr.Avatar = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-pull-labels", func(sl []string) { m.Curr.Commit.PullRequestLabels = sl }, c.StringSlice) metadataFileAndOverrideOrDefault(c, "commit-pull-milestone", func(s string) { m.Curr.Commit.PullRequestMilestone = s }, c.String) metadataFileAndOverrideOrDefault(c, "commit-release-is-pre", func(b bool) { m.Curr.Commit.IsPrerelease = b }, c.Bool) // Previous Pipeline metadataFileAndOverrideOrDefault(c, "prev-pipeline-number", func(i int64) { m.Prev.Number = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "prev-pipeline-created", func(i int64) { m.Prev.Created = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "prev-pipeline-started", func(i int64) { m.Prev.Started = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "prev-pipeline-finished", func(i int64) { m.Prev.Finished = i }, c.Int64) metadataFileAndOverrideOrDefault(c, "prev-pipeline-status", func(s string) { m.Prev.Status = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-pipeline-event", func(s string) { m.Prev.Event = metadata.Event(s) }, c.String) metadataFileAndOverrideOrDefault(c, "prev-pipeline-url", func(s string) { m.Prev.ForgeURL = s }, c.String) // Previous Pipeline Commit metadataFileAndOverrideOrDefault(c, "prev-commit-sha", func(s string) { m.Prev.Commit.Sha = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-commit-ref", func(s string) { m.Prev.Commit.Ref = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-commit-refspec", func(s string) { m.Prev.Commit.Refspec = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-commit-branch", func(s string) { m.Prev.Commit.Branch = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-commit-message", func(s string) { m.Prev.Commit.Message = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-commit-author-name", func(s string) { m.Prev.Commit.Author.Name = s }, c.String) metadataFileAndOverrideOrDefault(c, "prev-commit-author-email", func(s string) { m.Prev.Commit.Author.Email = s }, c.String) // TODO remove in next major metadataFileAndOverrideOrDefault(c, "prev-commit-author-avatar", func(s string) { m.Prev.Avatar = s }, c.String) // Workflow metadataFileAndOverrideOrDefault(c, "workflow-name", func(s string) { m.Workflow.Name = s }, c.String) metadataFileAndOverrideOrDefault(c, "workflow-number", func(i int64) { m.Workflow.Number = int(i) }, c.Int64) m.Workflow.Matrix = axis // System metadataFileAndOverrideOrDefault(c, "system-name", func(s string) { m.Sys.Name = s }, c.String) metadataFileAndOverrideOrDefault(c, "system-url", func(s string) { m.Sys.URL = s }, c.String) metadataFileAndOverrideOrDefault(c, "system-host", func(s string) { m.Sys.Host = s }, c.String) m.Sys.Platform = platform m.Sys.Version = version.Version // Forge metadataFileAndOverrideOrDefault(c, "forge-type", func(s string) { m.Forge.Type = s }, c.String) metadataFileAndOverrideOrDefault(c, "forge-url", func(s string) { m.Forge.URL = s }, c.String) return m, nil } // metadataFileAndOverrideOrDefault will either use the flag default or if metadata file is set only overload if explicit set. func metadataFileAndOverrideOrDefault[T any](c *cli.Command, flag string, setter func(T), getter func(string) T) { if !c.IsSet("metadata-file") || c.IsSet(flag) { setter(getter(flag)) } } ================================================ FILE: cli/exec/metadata_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package exec import ( "context" "encoding/json" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix" ) func TestMetadataFromContext(t *testing.T) { sampleMetadata := &metadata.Metadata{ Repo: metadata.Repo{Owner: "test-user", Name: "test-repo"}, Curr: metadata.Pipeline{Number: 5}, } runCommand := func(flags []cli.Flag, fn func(c *cli.Command)) { c := &cli.Command{ Flags: flags, Action: func(_ context.Context, c *cli.Command) error { fn(c) return nil }, } assert.NoError(t, c.Run(t.Context(), []string{"woodpecker-cli"})) } t.Run("LoadFromFile", func(t *testing.T) { tempFileName := createTempFile(t, sampleMetadata) flags := []cli.Flag{ &cli.StringFlag{Name: "metadata-file"}, } runCommand(flags, func(c *cli.Command) { _ = c.Set("metadata-file", tempFileName) m, err := metadataFromContext(t.Context(), c, nil, nil) require.NoError(t, err) assert.Equal(t, "test-repo", m.Repo.Name) assert.Equal(t, int64(5), m.Curr.Number) }) }) t.Run("OverrideFromFlags", func(t *testing.T) { tempFileName := createTempFile(t, sampleMetadata) flags := []cli.Flag{ &cli.StringFlag{Name: "metadata-file"}, &cli.StringFlag{Name: "repo-name"}, &cli.Int64Flag{Name: "pipeline-number"}, } runCommand(flags, func(c *cli.Command) { _ = c.Set("metadata-file", tempFileName) _ = c.Set("repo-name", "aUser/override-repo") _ = c.Set("pipeline-number", "10") m, err := metadataFromContext(t.Context(), c, nil, nil) require.NoError(t, err) assert.Equal(t, "override-repo", m.Repo.Name) assert.Equal(t, int64(10), m.Curr.Number) }) }) t.Run("InvalidFile", func(t *testing.T) { tempFile, err := os.CreateTemp(t.TempDir(), "invalid.json") require.NoError(t, err) t.Cleanup(func() { os.Remove(tempFile.Name()) }) _, err = tempFile.Write([]byte("invalid json")) require.NoError(t, err) flags := []cli.Flag{ &cli.StringFlag{Name: "metadata-file"}, } runCommand(flags, func(c *cli.Command) { _ = c.Set("metadata-file", tempFile.Name()) _, err = metadataFromContext(t.Context(), c, nil, nil) assert.Error(t, err) }) }) t.Run("DefaultValues", func(t *testing.T) { flags := []cli.Flag{ &cli.StringFlag{Name: "repo-name", Value: "test/default-repo"}, &cli.Int64Flag{Name: "pipeline-number", Value: 1}, } runCommand(flags, func(c *cli.Command) { m, err := metadataFromContext(t.Context(), c, nil, nil) require.NoError(t, err) if assert.NotNil(t, m) { assert.Equal(t, "test", m.Repo.Owner) assert.Equal(t, "default-repo", m.Repo.Name) assert.Equal(t, int64(1), m.Curr.Number) } }) }) t.Run("MatrixAxis", func(t *testing.T) { runCommand([]cli.Flag{}, func(c *cli.Command) { axis := matrix.Axis{"go": "1.16", "os": "linux"} m, err := metadataFromContext(t.Context(), c, axis, nil) require.NoError(t, err) assert.EqualValues(t, map[string]string{"go": "1.16", "os": "linux"}, m.Workflow.Matrix) }) }) } func createTempFile(t *testing.T, content any) string { t.Helper() tempFile, err := os.CreateTemp(t.TempDir(), "metadata.json") require.NoError(t, err) t.Cleanup(func() { os.Remove(tempFile.Name()) }) err = json.NewEncoder(tempFile).Encode(content) require.NoError(t, err) return tempFile.Name() } ================================================ FILE: cli/info/info.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package info import ( "context" "os" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) // Command exports the info command. var Command = &cli.Command{ Name: "info", Usage: "show information about the current user", ArgsUsage: " ", Action: info, Flags: []cli.Flag{common.FormatFlag(tmplInfo, true)}, } func info(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } user, err := client.Self() if err != nil { return err } tmpl, err := template.New("_").Parse(c.String("format") + "\n") if err != nil { return err } return tmpl.Execute(os.Stdout, user) } // Template for user information. var tmplInfo = `User: {{ .Login }} Email: {{ .Email }}` ================================================ FILE: cli/internal/config/config.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "context" "encoding/json" "errors" "os" "slices" "github.com/adrg/xdg" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "github.com/zalando/go-keyring" ) type Config struct { ServerURL string `json:"server_url"` Token string `json:"-"` LogLevel string `json:"log_level"` } func (c *Config) MergeIfNotSet(c2 *Config) { if c.ServerURL == "" { c.ServerURL = c2.ServerURL } if c.Token == "" { c.Token = c2.Token } if c.LogLevel == "" { c.LogLevel = c2.LogLevel } } var skipSetupForCommands = []string{"setup", "help", "h", "version", "update", "lint", "exec", "completion", "", "context", "ctx"} func Load(ctx context.Context, c *cli.Command) error { if firstArg := c.Args().First(); slices.Contains(skipSetupForCommands, firstArg) { return nil } contextConfig, contextErr := GetCurrentContext(ctx, c) if contextErr == nil { if !c.IsSet("server") { err := c.Set("server", contextConfig.ServerURL) if err != nil { return err } } if !c.IsSet("token") { err := c.Set("token", contextConfig.Token) if err != nil { return err } } if !c.IsSet("log-level") && contextConfig.LogLevel != "" { err := c.Set("log-level", contextConfig.LogLevel) if err != nil { return err } } log.Debug().Any("config", contextConfig).Msg("loaded config from context") return nil } // TODO: remove with next major release // Fallback: try legacy config file (for backward compatibility) config, err := Get(ctx, c, c.String("config")) if err != nil { return err } if config.ServerURL == "" || config.Token == "" { log.Info().Msg("woodpecker-cli is not set up, run `woodpecker-cli setup` to create a context") return errors.New("woodpecker-cli is not configured") } err = c.Set("server", config.ServerURL) if err != nil { return err } err = c.Set("token", config.Token) if err != nil { return err } err = c.Set("log-level", config.LogLevel) if err != nil { return err } log.Debug().Any("config", config).Msg("loaded config from legacy file") return nil } func getConfigPath(configPath string) (string, error) { if configPath != "" { return configPath, nil } configPath, err := xdg.ConfigFile("woodpecker/config.json") if err != nil { return "", err } return configPath, nil } func Get(_ context.Context, c *cli.Command, _configPath string) (*Config, error) { conf := &Config{ LogLevel: c.String("log-level"), Token: c.String("token"), ServerURL: c.String("server"), } configPath, err := getConfigPath(_configPath) if err != nil { return nil, err } log.Debug().Str("configPath", configPath).Msg("checking for config file") content, err := os.ReadFile(configPath) switch { case err != nil && !os.IsNotExist(err): log.Debug().Err(err).Msg("failed to read the config file") return nil, err case err != nil && os.IsNotExist(err): log.Debug().Msg("config file does not exist") default: configFromFile := &Config{} err = json.Unmarshal(content, configFromFile) if err != nil { return nil, err } conf.MergeIfNotSet(configFromFile) log.Debug().Msg("loaded config from file") } // if server or token are explicitly set, use them if c.IsSet("server") || c.IsSet("token") { return conf, nil } // load token from keyring service := c.Root().Name secret, err := keyring.Get(service, conf.ServerURL) if errors.Is(err, keyring.ErrUnsupportedPlatform) { log.Warn().Msg("keyring is not supported on this platform") return conf, nil } if errors.Is(err, keyring.ErrNotFound) { log.Warn().Msg("token not found in keyring") return conf, nil } conf.Token = secret return conf, nil } func Save(_ context.Context, c *cli.Command, _configPath string, conf *Config) error { config, err := json.Marshal(conf) if err != nil { return err } configPath, err := getConfigPath(_configPath) if err != nil { return err } // save token to keyring service := c.Root().Name err = keyring.Set(service, conf.ServerURL, conf.Token) if err != nil { return err } err = os.WriteFile(configPath, config, 0o600) if err != nil { return err } return nil } ================================================ FILE: cli/internal/config/config_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "testing" "github.com/stretchr/testify/assert" ) func TestConfigMerge(t *testing.T) { config := &Config{ ServerURL: "http://localhost:8080", Token: "1234567890", LogLevel: "debug", } configFromFile := &Config{ ServerURL: "https://ci.woodpecker-ci.org", Token: "", LogLevel: "info", } config.MergeIfNotSet(configFromFile) assert.Equal(t, config.ServerURL, "http://localhost:8080") assert.Equal(t, config.Token, "1234567890") assert.Equal(t, config.LogLevel, "debug") } ================================================ FILE: cli/internal/config/context.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "github.com/adrg/xdg" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "github.com/zalando/go-keyring" ) // Context represents a single CLI context with its connection details. type Context struct { Name string `json:"name"` ServerURL string `json:"server_url"` LogLevel string `json:"log_level,omitempty"` } // Contexts holds all contexts and tracks the current active one. type Contexts struct { CurrentContext string `json:"current_context"` Contexts map[string]Context `json:"contexts"` } func getContextsPath() (string, error) { configPath, err := xdg.ConfigFile("woodpecker/contexts.json") if err != nil { return "", err } return configPath, nil } // LoadContexts loads all contexts from the contexts file. func LoadContexts() (*Contexts, error) { contextsPath, err := getContextsPath() if err != nil { return nil, err } content, err := os.ReadFile(contextsPath) if err != nil { if os.IsNotExist(err) { return &Contexts{ Contexts: make(map[string]Context), }, nil } return nil, err } var contexts Contexts err = json.Unmarshal(content, &contexts) if err != nil { return nil, err } if contexts.Contexts == nil { contexts.Contexts = make(map[string]Context) } return &contexts, nil } // SaveContexts saves all contexts to the contexts file. func SaveContexts(contexts *Contexts) error { data, err := json.MarshalIndent(contexts, "", " ") if err != nil { return err } contextsPath, err := getContextsPath() if err != nil { return err } // Ensure the directory exists. dir := filepath.Dir(contextsPath) if err := os.MkdirAll(dir, 0o755); err != nil { return err } return os.WriteFile(contextsPath, data, 0o600) } // GetCurrentContext returns the current active context. func GetCurrentContext(ctx context.Context, c *cli.Command) (*Config, error) { contexts, err := LoadContexts() if err != nil { return nil, err } if contexts.CurrentContext == "" { return nil, errors.New("no context is currently set") } context, exists := contexts.Contexts[contexts.CurrentContext] if !exists { return nil, fmt.Errorf("current context '%s' not found", contexts.CurrentContext) } return GetContextConfig(c, &context) } // GetContextConfig loads the config for a specific context including the token from keyring. func GetContextConfig(c *cli.Command, ctx *Context) (*Config, error) { conf := &Config{ ServerURL: ctx.ServerURL, LogLevel: ctx.LogLevel, } // Load token from keyring service := c.Root().Name secret, err := keyring.Get(service, ctx.ServerURL) if errors.Is(err, keyring.ErrUnsupportedPlatform) { log.Warn().Msg("keyring is not supported on this platform") return conf, nil } if errors.Is(err, keyring.ErrNotFound) { return nil, fmt.Errorf("token not found in keyring for context '%s'", ctx.Name) } if err != nil { return nil, err } conf.Token = secret return conf, nil } // AddOrUpdateContext adds or updates a context and optionally sets it as current. func AddOrUpdateContext(c *cli.Command, name, serverURL, token, logLevel string, setCurrent bool) error { contexts, err := LoadContexts() if err != nil { return err } contexts.Contexts[name] = Context{ Name: name, ServerURL: serverURL, LogLevel: logLevel, } if setCurrent || contexts.CurrentContext == "" { contexts.CurrentContext = name } // Save token to keyring service := c.Root().Name err = keyring.Set(service, serverURL, token) if err != nil { return err } return SaveContexts(contexts) } // DeleteContext removes a context. func DeleteContext(c *cli.Command, name string) error { contexts, err := LoadContexts() if err != nil { return err } context, exists := contexts.Contexts[name] if !exists { return fmt.Errorf("context '%s' not found", name) } // Try to delete token from keyring service := c.Root().Name err = keyring.Delete(service, context.ServerURL) if err != nil && !errors.Is(err, keyring.ErrNotFound) { log.Warn().Err(err).Msg("failed to delete token from keyring") } delete(contexts.Contexts, name) // If we deleted the current context, unset it if contexts.CurrentContext == name { contexts.CurrentContext = "" } return SaveContexts(contexts) } // SetCurrentContext sets the current active context. func SetCurrentContext(name string) error { contexts, err := LoadContexts() if err != nil { return err } if _, exists := contexts.Contexts[name]; !exists { return fmt.Errorf("context '%s' not found", name) } contexts.CurrentContext = name return SaveContexts(contexts) } // RenameContext renames an existing context. func RenameContext(oldName, newName string) error { contexts, err := LoadContexts() if err != nil { return err } context, exists := contexts.Contexts[oldName] if !exists { return fmt.Errorf("context '%s' not found", oldName) } if _, exists := contexts.Contexts[newName]; exists { return fmt.Errorf("context '%s' already exists", newName) } // Update the name in the context context.Name = newName contexts.Contexts[newName] = context delete(contexts.Contexts, oldName) // Update current context if necessary if contexts.CurrentContext == oldName { contexts.CurrentContext = newName } return SaveContexts(contexts) } ================================================ FILE: cli/internal/config/context_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "os" "testing" "github.com/adrg/xdg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestContextManagement(t *testing.T) { // Create a temporary directory for test contexts tmpDir := t.TempDir() // Override xdg directories for testing t.Setenv("HOME", tmpDir) xdg.Reload() contextsFile, err := xdg.ConfigFile("woodpecker/contexts.json") _ = os.Remove(contextsFile) require.NoError(t, err) t.Run("LoadContexts returns empty when file doesn't exist", func(t *testing.T) { contexts, err := LoadContexts() require.NoError(t, err) assert.NotNil(t, contexts) assert.Empty(t, contexts.Contexts) assert.Empty(t, contexts.CurrentContext) }) t.Run("SaveContexts creates valid JSON", func(t *testing.T) { contexts := &Contexts{ CurrentContext: "test", Contexts: map[string]Context{ "test": { Name: "test", ServerURL: "https://test.example.com", LogLevel: "info", }, }, } err := SaveContexts(contexts) require.NoError(t, err) // Verify file exists and contains valid JSON data, err := os.ReadFile(contextsFile) require.NoError(t, err) assert.Contains(t, string(data), "test.example.com") }) t.Run("LoadContexts reads saved contexts", func(t *testing.T) { contexts, err := LoadContexts() require.NoError(t, err) assert.Equal(t, "test", contexts.CurrentContext) assert.Len(t, contexts.Contexts, 1) assert.Equal(t, "https://test.example.com", contexts.Contexts["test"].ServerURL) }) t.Run("SetCurrentContext updates current context", func(t *testing.T) { contexts := &Contexts{ CurrentContext: "test", Contexts: map[string]Context{ "test": { Name: "test", ServerURL: "https://test.example.com", }, "prod": { Name: "prod", ServerURL: "https://prod.example.com", }, }, } err := SaveContexts(contexts) require.NoError(t, err) err = SetCurrentContext("prod") require.NoError(t, err) contexts, err = LoadContexts() require.NoError(t, err) assert.Equal(t, "prod", contexts.CurrentContext) }) t.Run("SetCurrentContext fails for non-existent context", func(t *testing.T) { err := SetCurrentContext("nonexistent") assert.Error(t, err) assert.Contains(t, err.Error(), "not found") }) t.Run("RenameContext updates context name", func(t *testing.T) { contexts := &Contexts{ CurrentContext: "old", Contexts: map[string]Context{ "old": { Name: "old", ServerURL: "https://test.example.com", }, }, } err := SaveContexts(contexts) require.NoError(t, err) err = RenameContext("old", "new") require.NoError(t, err) contexts, err = LoadContexts() require.NoError(t, err) assert.Equal(t, "new", contexts.CurrentContext) assert.Contains(t, contexts.Contexts, "new") assert.NotContains(t, contexts.Contexts, "old") assert.Equal(t, "new", contexts.Contexts["new"].Name) }) t.Run("RenameContext fails if target exists", func(t *testing.T) { contexts := &Contexts{ Contexts: map[string]Context{ "ctx1": {Name: "ctx1", ServerURL: "https://test1.example.com"}, "ctx2": {Name: "ctx2", ServerURL: "https://test2.example.com"}, }, } err := SaveContexts(contexts) require.NoError(t, err) err = RenameContext("ctx1", "ctx2") assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") }) } ================================================ FILE: cli/internal/util.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package internal import ( "context" "crypto/tls" "crypto/x509" "fmt" "net/http" "os/exec" "strconv" "strings" "github.com/gitsight/go-vcsurl" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "golang.org/x/net/proxy" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // NewClient returns a new client from the CLI context. func NewClient(ctx context.Context, c *cli.Command) (woodpecker.Client, error) { var ( skip = c.Bool("skip-verify") socks = c.String("socks-proxy") socksOff = c.Bool("socks-proxy-off") token = c.String("token") server = c.String("server") ) server = strings.TrimRight(server, "/") // if no server url is provided we can default // to the hosted Woodpecker service. if len(server) == 0 { return nil, fmt.Errorf("you must provide the Woodpecker server address") } if len(token) == 0 { return nil, fmt.Errorf("you must provide your Woodpecker access token") } // attempt to find system CA certs certs, err := x509.SystemCertPool() if err != nil { log.Error().Err(err).Msg("failed to find system CA certs") } tlsConfig := &tls.Config{ RootCAs: certs, InsecureSkipVerify: skip, } config := new(oauth2.Config) client := config.Client(ctx, &oauth2.Token{ AccessToken: token, }, ) trans, _ := client.Transport.(*oauth2.Transport) var baseTransport http.RoundTripper if len(socks) != 0 && !socksOff { dialer, err := proxy.SOCKS5("tcp", socks, nil, proxy.Direct) if err != nil { return nil, err } baseTransport = &http.Transport{ TLSClientConfig: tlsConfig, Proxy: http.ProxyFromEnvironment, Dial: dialer.Dial, } } else { baseTransport = &http.Transport{ TLSClientConfig: tlsConfig, Proxy: http.ProxyFromEnvironment, } } // Wrap the base transport with User-Agent support trans.Base = httputil.NewUserAgentRoundTripper(baseTransport, "cli") return woodpecker.NewClient(server, client), nil } func getRepoFromGit(remoteName string) (string, error) { cmd := exec.Command("git", "remote", "get-url", remoteName) stdout, err := cmd.Output() if err != nil { return "", fmt.Errorf("could not get remote url: %w", err) } gitRemote := strings.TrimSpace(string(stdout)) log.Debug().Str("git-remote", gitRemote).Msg("extracted remote url from git") if len(gitRemote) == 0 { return "", fmt.Errorf("no repository provided") } u, err := vcsurl.Parse(gitRemote) if err != nil { return "", fmt.Errorf("could not parse git remote url: %w", err) } repoFullName := u.FullName log.Debug().Str("repo", repoFullName).Msg("extracted repository from remote url") return repoFullName, nil } // ParseRepo parses the repository owner and name from a string. func ParseRepo(client woodpecker.Client, str string) (repoID int64, err error) { if str == "" { str, err = getRepoFromGit("upstream") if err != nil { log.Debug().Err(err).Msg("could not get repository from git upstream remote") } } if str == "" { str, err = getRepoFromGit("origin") if err != nil { log.Debug().Err(err).Msg("could not get repository from git origin remote") } } if str == "" { return 0, fmt.Errorf("no repository provided") } if strings.Contains(str, "/") { repo, err := client.RepoLookup(str) if err != nil { return 0, err } return repo.ID, nil } return strconv.ParseInt(str, 10, 64) } // ParseKeyPair parses a key=value pair. func ParseKeyPair(p []string) map[string]string { params := map[string]string{} for _, i := range p { before, after, ok := strings.Cut(i, "=") if !ok || before == "" { continue } params[before] = after } return params } /* ParseStep parses the step id form a string which may either be the step PID (step number) or a step name. These rules apply: - Step PID take precedence over step name when searching for a match. - First match is used, when there are multiple steps with the same name. Strictly speaking, this is not parsing, but a lookup. */ func ParseStep(client woodpecker.Client, repoID, number int64, stepArg string) (stepID int64, err error) { pipeline, err := client.Pipeline(repoID, number) if err != nil { return 0, err } stepPID, err := strconv.ParseInt(stepArg, 10, 64) if err == nil { for _, wf := range pipeline.Workflows { for _, step := range wf.Children { if int64(step.PID) == stepPID { return step.ID, nil } } } } for _, wf := range pipeline.Workflows { for _, step := range wf.Children { if step.Name == stepArg { return step.ID, nil } } } return 0, fmt.Errorf("no step with number or name '%s' found", stepArg) } ================================================ FILE: cli/internal/util_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package internal import ( "testing" "github.com/stretchr/testify/assert" ) func TestParseKeyPair(t *testing.T) { s := []string{"FOO=bar", "BAR=", "BAZ=qux=quux", "INVALID"} p := ParseKeyPair(s) assert.Equal(t, "bar", p["FOO"]) assert.Equal(t, "qux=quux", p["BAZ"]) val, exists := p["BAR"] assert.Empty(t, val) assert.True(t, exists, "missing a key with no value, keys with empty values are also valid") _, exists = p["INVALID"] assert.False(t, exists, "keys without an equal sign suffix are invalid") } ================================================ FILE: cli/lint/lint.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package lint import ( "context" "fmt" "os" "path" "path/filepath" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) // Command exports the info command. var Command = &cli.Command{ Name: "lint", Usage: "lint a pipeline configuration file", ArgsUsage: "[path/to/.woodpecker.yaml]", Action: lint, Flags: []cli.Flag{ &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_PLUGINS_PRIVILEGED"), Name: "plugins-privileged", Usage: "allow plugins to run in privileged mode, if set empty, there is no", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_PLUGINS_TRUSTED_CLONE"), Name: "plugins-trusted-clone", Usage: "plugins that are trusted to handle Git credentials in cloning steps", Value: constant.TrustedClonePlugins, Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_LINT_STRICT"), Name: "strict", Usage: "treat warnings as errors", }, }, } func lint(ctx context.Context, c *cli.Command) error { return common.RunPipelineFunc(ctx, c, lintFile, lintDir) } func lintDir(ctx context.Context, c *cli.Command, dir string) error { var errorStrings []string if err := filepath.Walk(dir, func(path string, info os.FileInfo, e error) error { if e != nil { return e } // check if it is a regular file (not dir) if info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), ".yaml") || strings.HasSuffix(info.Name(), ".yml")) { fmt.Println("#", info.Name()) if err := lintFile(ctx, c, path); err != nil { errorStrings = append(errorStrings, err.Error()) } fmt.Println("") return nil } return nil }); err != nil { return err } if len(errorStrings) != 0 { return fmt.Errorf("ERRORS: %s", strings.Join(errorStrings, "; ")) } return nil } func lintFile(_ context.Context, c *cli.Command, file string) error { fi, err := os.Open(file) if err != nil { return err } defer fi.Close() buf, err := os.ReadFile(file) if err != nil { return err } rawConfig := string(buf) parsedConfig, err := yaml.ParseString(rawConfig) if err != nil { return err } config := &linter.WorkflowConfig{ File: path.Base(file), RawConfig: rawConfig, Workflow: parsedConfig, } // TODO: lint multiple files at once to allow checks for sth like "depends_on" to work err = linter.New( linter.WithTrusted(linter.TrustedConfiguration{ Network: true, Volumes: true, Security: true, }), linter.PrivilegedPlugins(c.StringSlice("plugins-privileged")), linter.WithTrustedClonePlugins(c.StringSlice("plugins-trusted-clone")), ).Lint([]*linter.WorkflowConfig{config}) if err != nil { str, err := FormatLintError(config.File, err, c.Bool("strict")) if str != "" { fmt.Print(str) } return err } fmt.Println("✅ Config is valid") return nil } ================================================ FILE: cli/lint/utils.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package lint import ( "errors" "fmt" "os" "github.com/muesli/termenv" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" ) func FormatLintError(file string, err error, strict bool) (string, error) { if err == nil { return "", nil } output := termenv.NewOutput(os.Stdout) str := "" amountErrors := 0 amountWarnings := 0 linterErrors := pipeline_errors.GetPipelineErrors(err) for _, err := range linterErrors { line := " " if !strict && err.IsWarning { line = fmt.Sprintf("%s ⚠️ ", line) amountWarnings++ } else { line = fmt.Sprintf("%s ❌", line) amountErrors++ } if data := pipeline_errors.GetLinterData(err); data != nil { line = fmt.Sprintf("%s %s\t%s", line, output.String(data.Field).Bold(), err.Message) } else { line = fmt.Sprintf("%s %s", line, err.Message) } // TODO: use table output str = fmt.Sprintf("%s%s\n", str, line) } if amountErrors > 0 { if amountWarnings > 0 { str = fmt.Sprintf("🔥 %s has %d errors and warnings:\n%s", output.String(file).Underline(), len(linterErrors), str) } else { str = fmt.Sprintf("🔥 %s has %d errors:\n%s", output.String(file).Underline(), len(linterErrors), str) } return str, errors.New("config has errors") } str = fmt.Sprintf("⚠️ %s has %d warnings:\n%s", output.String(file).Underline(), len(linterErrors), str) return str, nil } ================================================ FILE: cli/org/org.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package org import ( "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/org/registry" "go.woodpecker-ci.org/woodpecker/v3/cli/org/secret" ) // Command exports the org command set. var Command = &cli.Command{ Name: "org", Usage: "manage organizations", Commands: []*cli.Command{ registry.Command, secret.Command, }, } ================================================ FILE: cli/org/registry/registry.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the registry command set. var Command = &cli.Command{ Name: "registry", Usage: "manage organization registries", Commands: []*cli.Command{ registryCreateCmd, registryDeleteCmd, registryListCmd, registryShowCmd, registryUpdateCmd, }, } func parseTargetArgs(client woodpecker.Client, c *cli.Command) (orgID int64, err error) { orgIDOrName := c.String("organization") if orgIDOrName == "" { orgIDOrName = c.Args().First() } if orgIDOrName == "" { if err := cli.ShowSubcommandHelp(c); err != nil { return -1, err } } if orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil { return orgID, nil } org, err := client.OrgLookup(orgIDOrName) if err != nil { return -1, err } return org.ID, nil } ================================================ FILE: cli/org/registry/registry_add.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryCreateCmd = &cli.Command{ Name: "add", Usage: "add a registry", ArgsUsage: "[org-id|org-full-name]", Action: registryCreate, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, &cli.StringFlag{ Name: "username", Usage: "registry username", }, &cli.StringFlag{ Name: "password", Usage: "registry password", }, }, } func registryCreate(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") username = c.String("username") password = c.String("password") ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry := &woodpecker.Registry{ Address: hostname, Username: username, Password: password, } if strings.HasPrefix(registry.Password, "@") { path := strings.TrimPrefix(registry.Password, "@") out, err := os.ReadFile(path) if err != nil { return err } registry.Password = string(out) } orgID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.OrgRegistryCreate(orgID, registry) return err } ================================================ FILE: cli/org/registry/registry_list.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryListCmd = &cli.Command{ Name: "ls", Usage: "list registries", ArgsUsage: "[org-id|org-full-name]", Action: registryList, Flags: []cli.Flag{ common.OrgFlag, common.FormatFlag(tmplRegistryList, true), }, } func registryList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } orgID, err := parseTargetArgs(client, c) if err != nil { return err } opt := woodpecker.RegistryListOptions{} list, err := client.OrgRegistryList(orgID, opt) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } for _, registry := range list { if err := tmpl.Execute(os.Stdout, registry); err != nil { return err } } return nil } // Template for registry list information. var tmplRegistryList = "\x1b[33m{{ .Address }} \x1b[0m" + ` Username: {{ .Username }} Email: {{ .Email }} ` ================================================ FILE: cli/org/registry/registry_rm.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var registryDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a registry", ArgsUsage: "[org-id|org-full-name]", Action: registryDelete, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, }, } func registryDelete(ctx context.Context, c *cli.Command) error { hostname := c.String("hostname") client, err := internal.NewClient(ctx, c) if err != nil { return err } orgID, err := parseTargetArgs(client, c) if err != nil { return err } return client.OrgRegistryDelete(orgID, hostname) } ================================================ FILE: cli/org/registry/registry_set.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryUpdateCmd = &cli.Command{ Name: "update", Usage: "update a registry", ArgsUsage: "[org-id|org-full-name]", Action: registryUpdate, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, &cli.StringFlag{ Name: "username", Usage: "registry username", }, &cli.StringFlag{ Name: "password", Usage: "registry password", }, }, } func registryUpdate(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") username = c.String("username") password = c.String("password") ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry := &woodpecker.Registry{ Address: hostname, Username: username, Password: password, } if strings.HasPrefix(registry.Password, "@") { path := strings.TrimPrefix(registry.Password, "@") out, err := os.ReadFile(path) if err != nil { return err } registry.Password = string(out) } orgID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.OrgRegistryUpdate(orgID, registry) return err } ================================================ FILE: cli/org/registry/registry_show.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var registryShowCmd = &cli.Command{ Name: "show", Usage: "show registry information", ArgsUsage: "[org-id|org-full-name]", Action: registryShow, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, common.FormatFlag(tmplRegistryList, true), }, } func registryShow(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") format = c.String("format") + "\n" ) client, err := internal.NewClient(ctx, c) if err != nil { return err } orgID, err := parseTargetArgs(client, c) if err != nil { return err } registry, err := client.OrgRegistry(orgID, hostname) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, registry) } ================================================ FILE: cli/org/secret/secret.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the secret command. var Command = &cli.Command{ Name: "secret", Usage: "manage secrets", Commands: []*cli.Command{ secretCreateCmd, secretDeleteCmd, secretListCmd, secretShowCmd, secretUpdateCmd, }, } func parseTargetArgs(client woodpecker.Client, c *cli.Command) (orgID int64, err error) { orgIDOrName := c.String("organization") if orgIDOrName == "" { orgIDOrName = c.Args().First() } if orgIDOrName == "" { if err := cli.ShowSubcommandHelp(c); err != nil { return -1, err } } if orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil { return orgID, nil } org, err := client.OrgLookup(orgIDOrName) if err != nil { return -1, err } return org.ID, nil } ================================================ FILE: cli/org/secret/secret_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretCreateCmd = &cli.Command{ Name: "add", Usage: "add a secret", ArgsUsage: "[repo-id|repo-full-name]", Action: secretCreate, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, &cli.StringFlag{ Name: "value", Usage: "secret value", }, &cli.StringSliceFlag{ Name: "event", Usage: "secret limited to these events", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Name: "image", Usage: "secret limited to these images", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func secretCreate(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } secret := &woodpecker.Secret{ Name: strings.ToLower(c.String("name")), Value: c.String("value"), Images: c.StringSlice("image"), Events: c.StringSlice("event"), } if len(secret.Events) == 0 { secret.Events = defaultSecretEvents } if strings.HasPrefix(secret.Value, "@") { path := strings.TrimPrefix(secret.Value, "@") out, err := os.ReadFile(path) if err != nil { return err } secret.Value = string(out) } orgID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.OrgSecretCreate(orgID, secret) return err } var defaultSecretEvents = []string{ woodpecker.EventPush, woodpecker.EventTag, woodpecker.EventRelease, woodpecker.EventDeploy, } ================================================ FILE: cli/org/secret/secret_list.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "html/template" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretListCmd = &cli.Command{ Name: "ls", Usage: "list secrets", ArgsUsage: "[repo-id|repo-full-name]", Action: secretList, Flags: []cli.Flag{ common.OrgFlag, common.FormatFlag(tmplSecretList, true), }, } func secretList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } orgID, err := parseTargetArgs(client, c) if err != nil { return err } opt := woodpecker.SecretListOptions{} list, err := client.OrgSecretList(orgID, opt) if err != nil { return err } tmpl, err := template.New("_").Funcs(secretFuncMap).Parse(format) if err != nil { return err } for _, secret := range list { if err := tmpl.Execute(os.Stdout, secret); err != nil { return err } } return nil } // Template for secret list items. var tmplSecretList = "\x1b[33m{{ .Name }} \x1b[0m" + ` Events: {{ list .Events }} {{- if .Images }} Images: {{ list .Images }} {{- else }} Images: {{- end }} ` var secretFuncMap = template.FuncMap{ "list": func(s []string) string { return strings.Join(s, ", ") }, } ================================================ FILE: cli/org/secret/secret_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var secretDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a secret", ArgsUsage: "[repo-id|repo-full-name]", Action: secretDelete, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, }, } func secretDelete(ctx context.Context, c *cli.Command) error { secretName := c.String("name") client, err := internal.NewClient(ctx, c) if err != nil { return err } orgID, err := parseTargetArgs(client, c) if err != nil { return err } return client.OrgSecretDelete(orgID, secretName) } ================================================ FILE: cli/org/secret/secret_set.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretUpdateCmd = &cli.Command{ Name: "update", Usage: "update a secret", ArgsUsage: "[repo-id|repo-full-name]", Action: secretUpdate, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, &cli.StringFlag{ Name: "value", Usage: "secret value", }, &cli.StringSliceFlag{ Name: "event", Usage: "limit secret to these event", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Name: "image", Usage: "limit secret to these image", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func secretUpdate(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } secret := &woodpecker.Secret{ Name: strings.ToLower(c.String("name")), Value: c.String("value"), Images: c.StringSlice("image"), Events: c.StringSlice("event"), } if strings.HasPrefix(secret.Value, "@") { path := strings.TrimPrefix(secret.Value, "@") out, err := os.ReadFile(path) if err != nil { return err } secret.Value = string(out) } orgID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.OrgSecretUpdate(orgID, secret) return err } ================================================ FILE: cli/org/secret/secret_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "fmt" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var secretShowCmd = &cli.Command{ Name: "show", Usage: "show secret information", ArgsUsage: "[repo-id|repo-full-name]", Action: secretShow, Flags: []cli.Flag{ common.OrgFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, common.FormatFlag(tmplSecretList, true), }, } func secretShow(ctx context.Context, c *cli.Command) error { var ( secretName = c.String("name") format = c.String("format") + "\n" ) if secretName == "" { return fmt.Errorf("secret name is missing") } client, err := internal.NewClient(ctx, c) if err != nil { return err } orgID, err := parseTargetArgs(client, c) if err != nil { return err } secret, err := client.OrgSecret(orgID, secretName) if err != nil { return err } tmpl, err := template.New("_").Funcs(secretFuncMap).Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, secret) } ================================================ FILE: cli/output/output.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package output import ( "errors" "strings" ) var ErrOutputOptionRequired = errors.New("output option required") func ParseOutputOptions(out string) (string, []string) { out, opt, found := strings.Cut(out, "=") if !found { return out, nil } var optList []string if opt != "" { optList = strings.Split(opt, ",") } return out, optList } ================================================ FILE: cli/output/output_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package output import ( "testing" "github.com/stretchr/testify/assert" ) func TestParseOutputOptions(t *testing.T) { t.Parallel() testCases := []struct { in string out string opts []string }{ { in: "output", out: "output", }, { in: "output=a", out: "output", opts: []string{"a"}, }, { in: "output=", out: "output", }, { in: "output=a,b", out: "output", opts: []string{"a", "b"}, }, } for _, tc := range testCases { out, opts := ParseOutputOptions(tc.in) assert.Equal(t, tc.out, out) assert.Equal(t, tc.opts, opts) } } ================================================ FILE: cli/output/table.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package output import ( "fmt" "io" "reflect" "sort" "strings" "text/tabwriter" "unicode" "github.com/go-viper/mapstructure/v2" ) // NewTable creates a new Table. func NewTable(out io.Writer) *Table { padding := 2 return &Table{ w: tabwriter.NewWriter(out, 0, 0, padding, ' ', 0), columns: map[string]bool{}, fieldMapping: map[string]FieldFn{}, fieldAlias: map[string]string{}, allowedFields: map[string]bool{}, } } type FieldFn func(obj any) string type writerFlusher interface { io.Writer Flush() error } // Table is a generic way to format object as a table. type Table struct { w writerFlusher columns map[string]bool fieldMapping map[string]FieldFn fieldAlias map[string]string allowedFields map[string]bool } // Columns returns a list of known output columns. func (o *Table) Columns() (cols []string) { for c := range o.columns { cols = append(cols, c) } sort.Strings(cols) return cols } // AddFieldAlias overrides the field name to allow custom column headers. func (o *Table) AddFieldAlias(field, alias string) *Table { o.fieldAlias[strings.ToLower(alias)] = field return o } // AddFieldFn adds a function which handles the output of the specified field. func (o *Table) AddFieldFn(field string, fn FieldFn) *Table { o.fieldMapping[strings.ToLower(field)] = fn o.allowedFields[strings.ToLower(field)] = true o.columns[strings.ToLower(field)] = true return o } // AddAllowedFields reads all first level field names of the struct and allows them to be used. func (o *Table) AddAllowedFields(obj any) (*Table, error) { v := reflect.ValueOf(obj) if v.Kind() != reflect.Struct { return o, fmt.Errorf("AddAllowedFields input must be a struct") } t := v.Type() for i := 0; i < v.NumField(); i++ { k := t.Field(i).Type.Kind() if k != reflect.Bool && k != reflect.Float32 && k != reflect.Float64 && k != reflect.String && k != reflect.Int && k != reflect.Int64 { // only allow simple values // complex values need to be mapped via a FieldFn continue } o.allowedFields[strings.ToLower(t.Field(i).Name)] = true o.allowedFields[fieldName(t.Field(i).Name)] = true o.columns[fieldName(t.Field(i).Name)] = true } return o, nil } // RemoveAllowedField removes fields from the allowed list. func (o *Table) RemoveAllowedField(fields ...string) *Table { for _, field := range fields { delete(o.allowedFields, field) delete(o.columns, field) } return o } // ValidateColumns returns an error if invalid columns are specified. func (o *Table) ValidateColumns(cols []string) error { var invalidCols []string for _, col := range cols { if _, ok := o.allowedFields[strings.ToLower(col)]; !ok { invalidCols = append(invalidCols, col) } } if len(invalidCols) > 0 { return fmt.Errorf("invalid table columns: %s", strings.Join(invalidCols, ",")) } return nil } // WriteHeader writes the table header. func (o *Table) WriteHeader(columns []string) { var header []string for _, col := range columns { header = append(header, strings.ReplaceAll(strings.ToUpper(col), "_", " ")) } _, _ = fmt.Fprintln(o.w, strings.Join(header, "\t")) } func (o *Table) Flush() error { return o.w.Flush() } // Write writes a table line. func (o *Table) Write(columns []string, obj any) error { var data map[string]any if err := mapstructure.Decode(obj, &data); err != nil { return fmt.Errorf("failed to decode object: %w", err) } dataL := map[string]any{} for key, value := range data { dataL[strings.ToLower(key)] = value } var out []string for _, col := range columns { colName := strings.ToLower(col) if alias, ok := o.fieldAlias[colName]; ok { colName = strings.ToLower(alias) } if fn, ok := o.fieldMapping[strings.ReplaceAll(colName, "_", "")]; ok { out = append(out, sanitizeString(fn(obj))) continue } if value, ok := dataL[strings.ReplaceAll(colName, "_", "")]; ok { if value == nil { out = append(out, NA("")) continue } if b, ok := value.(bool); ok { out = append(out, YesNo(b)) continue } if s, ok := value.(string); ok { out = append(out, NA(sanitizeString(s))) continue } out = append(out, sanitizeString(value)) } } _, _ = fmt.Fprintln(o.w, strings.Join(out, "\t")) return nil } func NA(s string) string { if s == "" { return "-" } return s } func YesNo(b bool) string { if b { return "yes" } return "no" } func fieldName(name string) string { r := []rune(name) var out []rune for i := range r { if i > 0 && (unicode.IsUpper(r[i])) && (i+1 < len(r) && unicode.IsLower(r[i+1]) || unicode.IsLower(r[i-1])) { out = append(out, '_') } out = append(out, unicode.ToLower(r[i])) } return string(out) } func sanitizeString(value any) string { str := fmt.Sprintf("%v", value) replacer := strings.NewReplacer("\n", " ", "\r", " ") return strings.TrimSpace(replacer.Replace(str)) } ================================================ FILE: cli/output/table_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package output import ( "bytes" "os" "testing" "github.com/stretchr/testify/assert" ) type writerFlusherStub struct { bytes.Buffer } func (s writerFlusherStub) Flush() error { return nil } type testFieldsStruct struct { Name string Number int Bool bool } func TestTableOutput(t *testing.T) { var wfs writerFlusherStub to := NewTable(os.Stdout) to.w = &wfs t.Run("AddAllowedFields", func(t *testing.T) { _, _ = to.AddAllowedFields(testFieldsStruct{}) _, ok := to.allowedFields["name"] assert.True(t, ok) }) t.Run("AddFieldAlias", func(t *testing.T) { to.AddFieldAlias("WoodpeckerCI", "wp") alias, ok := to.fieldAlias["wp"] assert.True(t, ok) assert.Equal(t, "WoodpeckerCI", alias) }) t.Run("AddFieldOutputFn", func(t *testing.T) { to.AddFieldFn("WoodpeckerCI", FieldFn(func(_ any) string { return "WOODPECKER CI!!!" })) _, ok := to.fieldMapping["woodpeckerci"] assert.True(t, ok) }) t.Run("ValidateColumns", func(t *testing.T) { err := to.ValidateColumns([]string{"non-existent", "NAME"}) assert.Error(t, err) assert.ErrorContains(t, err, "non-existent") assert.NotContains(t, err.Error(), "name") assert.NoError(t, to.ValidateColumns([]string{"name"})) }) t.Run("WriteHeader", func(t *testing.T) { to.WriteHeader([]string{"wp", "name"}) assert.Equal(t, "WP\tNAME\n", wfs.String()) wfs.Reset() }) t.Run("WriteLine", func(t *testing.T) { err := to.Write([]string{"wp", "name", "number", "bool"}, &testFieldsStruct{"test123", 1000000000, true}) assert.NoError(t, err) err = to.Write([]string{"wp", "name", "number", "bool"}, &testFieldsStruct{"", 1000000000, false}) assert.NoError(t, err) assert.Equal(t, "WOODPECKER CI!!!\ttest123\t1000000000\tyes\nWOODPECKER CI!!!\t-\t1000000000\tno\n", wfs.String()) wfs.Reset() }) t.Run("Columns", func(t *testing.T) { assert.Len(t, to.Columns(), 4) }) } ================================================ FILE: cli/pipeline/approve.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var pipelineApproveCmd = &cli.Command{ Name: "approve", Usage: "approve a pipeline", ArgsUsage: " ", Action: pipelineApprove, } func pipelineApprove(ctx context.Context, c *cli.Command) (err error) { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } number, err := strconv.ParseInt(c.Args().Get(1), 10, 64) if err != nil { return err } _, err = client.PipelineApprove(repoID, number) if err != nil { return err } fmt.Printf("Approving pipeline %s#%d\n", repoIDOrFullName, number) return nil } ================================================ FILE: cli/pipeline/create.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var pipelineCreateCmd = &cli.Command{ Name: "create", Usage: "create new pipeline", ArgsUsage: "", Action: pipelineCreate, Flags: append(common.OutputFlags("table"), []cli.Flag{ &cli.StringFlag{ Name: "branch", Usage: "branch to create pipeline from", Required: true, }, &cli.StringSliceFlag{ Name: "var", Usage: "key=value", Config: cli.StringConfig{ TrimSpace: true, }, }, }...), } func pipelineCreate(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } branch := c.String("branch") variables := make(map[string]string) for _, vaz := range c.StringSlice("var") { before, after, _ := strings.Cut(vaz, "=") if before != "" && after != "" { variables[before] = after } } options := &woodpecker.PipelineOptions{ Branch: branch, Variables: variables, } pipeline, err := client.PipelineCreate(repoID, options) if err != nil { return err } return pipelineOutput(c, []*woodpecker.Pipeline{pipeline}) } ================================================ FILE: cli/pipeline/decline.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var pipelineDeclineCmd = &cli.Command{ Name: "decline", Usage: "decline a pipeline", ArgsUsage: " ", Action: pipelineDecline, } func pipelineDecline(ctx context.Context, c *cli.Command) (err error) { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } number, err := strconv.ParseInt(c.Args().Get(1), 10, 64) if err != nil { return err } _, err = client.PipelineDecline(repoID, number) if err != nil { return err } fmt.Printf("Declining pipeline %s#%d\n", repoIDOrFullName, number) return nil } ================================================ FILE: cli/pipeline/deploy/deploy.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package deploy import ( "context" "fmt" "html/template" "os" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the deploy command. var Command = &cli.Command{ Name: "deploy", Usage: "trigger a pipeline with the 'deployment' event", ArgsUsage: " ", Action: deploy, Flags: []cli.Flag{ common.FormatFlag(tmplDeployInfo, false), &cli.StringFlag{ Name: "branch", Usage: "branch filter", }, &cli.StringFlag{ Name: "event", Usage: "event filter", Value: woodpecker.EventPush, }, &cli.StringFlag{ Name: "status", Usage: "status filter", Value: woodpecker.StatusSuccess, }, &cli.StringSliceFlag{ Name: "param", Aliases: []string{"p"}, Usage: "custom parameters to inject into the step environment. Format: KEY=value", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func deploy(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } repo := c.Args().First() repoID, err := internal.ParseRepo(client, repo) if err != nil { return err } branch := c.String("branch") event := c.String("event") status := c.String("status") if branch == "" { repo, err := client.Repo(repoID) if err != nil { return err } branch = repo.Branch } pipelineArg := c.Args().Get(1) var number int64 if pipelineArg == "last" { // Fetch the pipeline number from the last pipeline pipelines, err := client.PipelineList(repoID, woodpecker.PipelineListOptions{}) if err != nil { return err } for _, pipeline := range pipelines { if branch != "" && pipeline.Branch != branch { continue } if event != "" && pipeline.Event != event { continue } if status != "" && pipeline.Status != status { continue } if pipeline.Number > number { number = pipeline.Number } } if number == 0 { return fmt.Errorf("cannot deploy failure pipeline") } } else { number, err = strconv.ParseInt(pipelineArg, 10, 64) if err != nil { return err } } envArgIndex := 2 env := c.Args().Get(envArgIndex) if env == "" { return fmt.Errorf("please specify the target environment (i.e. production)") } opt := woodpecker.DeployOptions{ DeployTo: env, Params: internal.ParseKeyPair(c.StringSlice("param")), } deploy, err := client.Deploy(repoID, number, opt) if err != nil { return err } tmpl, err := template.New("_").Parse(c.String("format")) if err != nil { return err } return tmpl.Execute(os.Stdout, deploy) } // Template for deployment information. var tmplDeployInfo = `Number: {{ .Number }} Status: {{ .Status }} Commit: {{ .Commit }} Branch: {{ .Branch }} Ref: {{ .Ref }} Message: {{ .Message }} Author: {{ .Author }} Target: {{ .Deploy }} ` ================================================ FILE: cli/pipeline/kill.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var pipelineKillCmd = &cli.Command{ Name: "kill", Usage: "force kill a pipeline", ArgsUsage: " ", Action: pipelineKill, Hidden: true, } func pipelineKill(ctx context.Context, c *cli.Command) (err error) { number, err := strconv.ParseInt(c.Args().Get(1), 10, 64) if err != nil { return err } repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } err = client.PipelineDelete(repoID, number) if err != nil { return err } fmt.Printf("Force killing pipeline %s#%d\n", repoIDOrFullName, number) return nil } ================================================ FILE: cli/pipeline/last.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var pipelineLastCmd = &cli.Command{ Name: "last", Usage: "show latest pipeline information", ArgsUsage: "", Action: pipelineLast, Flags: append(common.OutputFlags("table"), []cli.Flag{ &cli.StringFlag{ Name: "branch", Usage: "branch name", Value: "main", }, }...), } func pipelineLast(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } opt := woodpecker.PipelineLastOptions{ Branch: c.String("branch"), } pipeline, err := client.PipelineLast(repoID, opt) if err != nil { return err } return pipelineOutput(c, []*woodpecker.Pipeline{pipeline}) } ================================================ FILE: cli/pipeline/list.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "time" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) //nolint:mnd func buildPipelineListCmd() *cli.Command { return &cli.Command{ Name: "ls", Usage: "show pipeline history", ArgsUsage: "", Action: List, Flags: append(common.OutputFlags("table"), []cli.Flag{ &cli.StringFlag{ Name: "branch", Usage: "branch filter", }, &cli.StringFlag{ Name: "event", Usage: "event filter", }, &cli.StringFlag{ Name: "status", Usage: "status filter", }, &cli.IntFlag{ Name: "limit", Usage: "limit the list size", Value: 25, }, &cli.TimestampFlag{ Name: "before", Usage: "only return pipelines before this date (RFC3339)", Config: cli.TimestampConfig{ Layouts: []string{ time.RFC3339, }, }, }, &cli.TimestampFlag{ Name: "after", Usage: "only return pipelines after this date (RFC3339)", Config: cli.TimestampConfig{ Layouts: []string{ time.RFC3339, }, }, }, }...), } } func List(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } pipelines, err := pipelineList(c, client) if err != nil { return err } return pipelineOutput(c, pipelines) } func pipelineList(c *cli.Command, client woodpecker.Client) ([]*woodpecker.Pipeline, error) { repoIDOrFullName := c.Args().First() repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return nil, err } opt := woodpecker.PipelineListOptions{} if before := c.Timestamp("before"); !before.IsZero() { opt.Before = before } if after := c.Timestamp("after"); !after.IsZero() { opt.After = after } branch := c.String("branch") event := c.String("event") status := c.String("status") limit := c.Int("limit") pipelines, err := shared_utils.Paginate(func(page int) ([]*woodpecker.Pipeline, error) { return client.PipelineList(repoID, woodpecker.PipelineListOptions{ ListOptions: woodpecker.ListOptions{ Page: page, }, Before: opt.Before, After: opt.After, Branch: branch, Events: []string{event}, Status: status, }, ) }, limit) if err != nil { return nil, err } return pipelines, nil } ================================================ FILE: cli/pipeline/list_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "io" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks" ) func TestPipelineList(t *testing.T) { testtases := []struct { name string repoID int64 repoErr error pipelines []*woodpecker.Pipeline pipelineErr error args []string expected []*woodpecker.Pipeline wantErr error }{ { name: "success", repoID: 1, pipelines: []*woodpecker.Pipeline{ {ID: 1, Branch: "main", Event: "push", Status: "success"}, {ID: 2, Branch: "develop", Event: "pull_request", Status: "running"}, {ID: 3, Branch: "main", Event: "push", Status: "failure"}, }, args: []string{"ls", "repo/name"}, expected: []*woodpecker.Pipeline{ {ID: 1, Branch: "main", Event: "push", Status: "success"}, {ID: 2, Branch: "develop", Event: "pull_request", Status: "running"}, {ID: 3, Branch: "main", Event: "push", Status: "failure"}, }, }, { name: "limit results", repoID: 1, pipelines: []*woodpecker.Pipeline{ {ID: 1, Branch: "main", Event: "push", Status: "success"}, {ID: 2, Branch: "develop", Event: "pull_request", Status: "running"}, {ID: 3, Branch: "main", Event: "push", Status: "failure"}, }, args: []string{"ls", "--limit", "2", "repo/name"}, expected: []*woodpecker.Pipeline{ {ID: 1, Branch: "main", Event: "push", Status: "success"}, {ID: 2, Branch: "develop", Event: "pull_request", Status: "running"}, }, }, { name: "pipeline list error", repoID: 1, pipelineErr: errors.New("pipeline error"), args: []string{"ls", "repo/name"}, wantErr: errors.New("pipeline error"), }, } for _, tt := range testtases { t.Run(tt.name, func(t *testing.T) { mockClient := mocks.NewMockClient(t) mockClient.On("PipelineList", mock.Anything, mock.Anything).Return(func(_ int64, opt woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error) { if tt.pipelineErr != nil { return nil, tt.pipelineErr } if opt.Page == 1 { return tt.pipelines, nil } return []*woodpecker.Pipeline{}, nil }).Maybe() mockClient.On("RepoLookup", mock.Anything).Return(&woodpecker.Repo{ID: tt.repoID}, nil) command := buildPipelineListCmd() command.Writer = io.Discard command.Action = func(_ context.Context, c *cli.Command) error { pipelines, err := pipelineList(c, mockClient) if tt.wantErr != nil { assert.EqualError(t, err, tt.wantErr.Error()) return nil } assert.NoError(t, err) assert.EqualValues(t, tt.expected, pipelines) return nil } _ = command.Run(t.Context(), tt.args) }) } } ================================================ FILE: cli/pipeline/log/log.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log import ( "github.com/urfave/cli/v3" ) // Command exports the log command set. var Command = &cli.Command{ Name: "log", Usage: "manage logs", Commands: []*cli.Command{ logPurgeCmd, logShowCmd, }, } ================================================ FILE: cli/pipeline/log/log_purge.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log import ( "context" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var logPurgeCmd = &cli.Command{ Name: "purge", Usage: "purge a log", ArgsUsage: " [step-number|step-name]", Action: logPurge, } func logPurge(ctx context.Context, c *cli.Command) (err error) { client, err := internal.NewClient(ctx, c) if err != nil { return err } repoIDOrFullName := c.Args().First() if len(repoIDOrFullName) == 0 { return fmt.Errorf("missing required argument repo-id / repo-full-name") } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return fmt.Errorf("invalid repo '%s': %w", repoIDOrFullName, err) } pipelineArg := c.Args().Get(1) if len(pipelineArg) == 0 { return fmt.Errorf("missing required argument pipeline") } number, err := strconv.ParseInt(pipelineArg, 10, 64) if err != nil { return err } stepArg := c.Args().Get(2) //nolint:mnd var stepID int64 if len(stepArg) != 0 { stepID, err = internal.ParseStep(client, repoID, number, stepArg) if err != nil { return err } } if stepID > 0 { fmt.Printf("Purging logs for pipeline %s#%d step %d\n", repoIDOrFullName, number, stepID) err = client.StepLogsPurge(repoID, number, stepID) } else { fmt.Printf("Purging logs for pipeline %s#%d\n", repoIDOrFullName, number) err = client.LogsPurge(repoID, number) } if err != nil { return err } return nil } ================================================ FILE: cli/pipeline/log/log_show.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log import ( "context" "fmt" "os" "strconv" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var logShowCmd = &cli.Command{ Name: "show", Usage: "show pipeline logs", ArgsUsage: " [step-number|step-name]", Action: logShow, } func logShow(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } if len(repoIDOrFullName) == 0 { return fmt.Errorf("missing required argument repo-id / repo-full-name") } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return fmt.Errorf("invalid repo '%s': %w ", repoIDOrFullName, err) } pipelineArg := c.Args().Get(1) if len(pipelineArg) == 0 { return fmt.Errorf("missing required argument pipeline") } number, err := strconv.ParseInt(pipelineArg, 10, 64) if err != nil { return fmt.Errorf("invalid pipeline '%s': %w", pipelineArg, err) } stepArg := c.Args().Get(2) //nolint:mnd if len(stepArg) == 0 { return pipelineLog(client, repoID, number) } step, err := internal.ParseStep(client, repoID, number, stepArg) if err != nil { return fmt.Errorf("invalid step '%s': %w", stepArg, err) } return stepLog(client, repoID, number, step) } func pipelineLog(client woodpecker.Client, repoID, number int64) error { pipeline, err := client.Pipeline(repoID, number) if err != nil { return err } tmpl, err := template.New("_").Parse(tmplPipelineLogs + "\n") if err != nil { return err } for _, workflow := range pipeline.Workflows { for _, step := range workflow.Children { if err := tmpl.Execute(os.Stdout, map[string]any{"workflow": workflow, "step": step}); err != nil { return err } err := stepLog(client, repoID, number, step.ID) if err != nil { return err } } } return nil } func stepLog(client woodpecker.Client, repoID, number, step int64) error { logs, err := client.StepLogEntries(repoID, number, step) if err != nil { return err } for _, log := range logs { fmt.Println(string(log.Data)) } return nil } // template for pipeline ps information. var tmplPipelineLogs = "\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\x1b[0m" ================================================ FILE: cli/pipeline/pipeline.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "fmt" "io" "os" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/output" "go.woodpecker-ci.org/woodpecker/v3/cli/pipeline/deploy" "go.woodpecker-ci.org/woodpecker/v3/cli/pipeline/log" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the pipeline command set. var Command = &cli.Command{ Name: "pipeline", Usage: "manage pipelines", Commands: []*cli.Command{ pipelineApproveCmd, pipelineCreateCmd, pipelineDeclineCmd, deploy.Command, pipelineKillCmd, pipelineLastCmd, buildPipelineListCmd(), log.Command, pipelinePsCmd, pipelinePurgeCmd, pipelineQueueCmd, pipelineShowCmd, pipelineStartCmd, pipelineStopCmd, }, } func pipelineOutput(c *cli.Command, pipelines []*woodpecker.Pipeline, fd ...io.Writer) error { outFmt, outOpt := output.ParseOutputOptions(c.String("output")) noHeader := c.Bool("output-no-headers") var out io.Writer out = os.Stdout if len(fd) > 0 { out = fd[0] } switch outFmt { case "go-template": if len(outOpt) < 1 { return fmt.Errorf("%w: missing template", output.ErrOutputOptionRequired) } tmpl, err := template.New("_").Parse(outOpt[0] + "\n") if err != nil { return err } if err := tmpl.Execute(out, pipelines); err != nil { return err } case "go-format": if len(outOpt) < 1 { return fmt.Errorf("%w: missing template", output.ErrOutputOptionRequired) } tmpl, err := template.New("_").Parse(outOpt[0] + "\n") if err != nil { return err } for _, p := range pipelines { if err := tmpl.Execute(out, p); err != nil { return err } } case "table": fallthrough default: table := output.NewTable(out) cols := []string{"Number", "Status", "Event", "Branch", "Message", "Author"} if len(outOpt) > 0 { cols = outOpt } if !noHeader { table.WriteHeader(cols) } for _, resource := range pipelines { if err := table.Write(cols, resource); err != nil { return err } } table.Flush() } return nil } ================================================ FILE: cli/pipeline/pipeline_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "bytes" "context" "io" "testing" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) func TestPipelineOutput(t *testing.T) { tests := []struct { name string args []string expected string wantErr bool }{ { name: "table output with default columns", args: []string{}, expected: "NUMBER STATUS EVENT BRANCH MESSAGE AUTHOR\n1 success push main message multiline John Doe\n", }, { name: "table output with custom columns", args: []string{"output", "--output", "table=Number,Status,Branch"}, expected: "NUMBER STATUS BRANCH\n1 success main\n", }, { name: "table output with no header", args: []string{"output", "--output-no-headers"}, expected: "1 success push main message multiline John Doe\n", }, { name: "go-template output", args: []string{"output", "--output", "go-template={{range . }}{{.Number}} {{.Status}} {{.Branch}}{{end}}"}, expected: "1 success main\n", }, { name: "go-format output", args: []string{"output", "--output", "go-format={{.Number}} {{.Status}} {{.Branch}}"}, expected: "1 success main\n", }, { name: "invalid go-template", args: []string{"output", "--output", "go-template={{.InvalidField}}"}, wantErr: true, }, } pipelines := []*woodpecker.Pipeline{ { Number: 1, Status: "success", Event: "push", Branch: "main", Message: "message\nmultiline", Author: "John Doe\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { command := &cli.Command{ Writer: io.Discard, Name: "output", Flags: common.OutputFlags("table"), Action: func(_ context.Context, c *cli.Command) error { var buf bytes.Buffer err := pipelineOutput(c, pipelines, &buf) if tt.wantErr { assert.Error(t, err) return nil } assert.NoError(t, err) assert.Equal(t, tt.expected, buf.String()) return nil }, } _ = command.Run(t.Context(), tt.args) }) } } ================================================ FILE: cli/pipeline/ps.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "os" "strconv" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var pipelinePsCmd = &cli.Command{ Name: "ps", Usage: "show pipeline steps", ArgsUsage: " ", Action: pipelinePs, Flags: []cli.Flag{common.FormatFlag(tmplPipelinePs, false)}, } func pipelinePs(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return fmt.Errorf("invalid repo '%s': %w", repoIDOrFullName, err) } pipelineArg := c.Args().Get(1) var number int64 if pipelineArg == "last" || len(pipelineArg) == 0 { // Fetch the pipeline number from the last pipeline pipeline, err := client.PipelineLast(repoID, woodpecker.PipelineLastOptions{}) if err != nil { return err } number = pipeline.Number } else { number, err = strconv.ParseInt(pipelineArg, 10, 64) if err != nil { return fmt.Errorf("invalid pipeline '%s': %w", pipelineArg, err) } } pipeline, err := client.Pipeline(repoID, number) if err != nil { return err } tmpl, err := template.New("_").Parse(c.String("format") + "\n") if err != nil { return err } for _, workflow := range pipeline.Workflows { for _, step := range workflow.Children { if err := tmpl.Execute(os.Stdout, map[string]any{"workflow": workflow, "step": step}); err != nil { return err } } } return nil } // template for pipeline ps information. var tmplPipelinePs = "\x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\x1b[0m" + ` Step: {{ .step.Name }} Started: {{ .step.Started }} Stopped: {{ .step.Stopped }} Type: {{ .step.Type }} State: {{ .step.State }} ` ================================================ FILE: cli/pipeline/purge.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "fmt" "net/http" "time" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) //nolint:mnd var pipelinePurgeCmd = &cli.Command{ Name: "purge", Usage: "purge pipelines", ArgsUsage: "", Action: Purge, Flags: []cli.Flag{ &cli.StringFlag{ Name: "branch", Usage: "remove pipelines of this branch only", }, &cli.DurationFlag{ Name: "older-than", Usage: "remove pipelines older than the specified time limit", Required: true, }, &cli.Int64Flag{ Name: "keep-min", Usage: "minimum number of pipelines to keep", Value: 10, }, &cli.BoolFlag{ Name: "dry-run", Usage: "disable non-read api calls", Value: false, }, }, } func Purge(ctx context.Context, c *cli.Command) error { start := time.Now() client, err := internal.NewClient(ctx, c) if err != nil { return err } return pipelinePurge(c, client, start) } func pipelinePurge(c *cli.Command, client woodpecker.Client, start time.Time) (err error) { repoIDOrFullName := c.Args().First() if len(repoIDOrFullName) == 0 { return fmt.Errorf("missing required argument repo-id / repo-full-name") } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return fmt.Errorf("invalid repo '%s': %w", repoIDOrFullName, err) } branch := c.String("branch") olderThan := c.Duration("older-than") keepMin := c.Int64("keep-min") dryRun := c.Bool("dry-run") var before time.Time if !start.IsZero() { before = start.Add(-olderThan) } var pipelinesKeep []*woodpecker.Pipeline if keepMin > 0 { pipelinesKeep, err = fetchPipelinesToKeep(client, repoID, branch, int(keepMin)) if err != nil { return err } } pipelines, err := fetchPipelines(client, repoID, branch, before) if err != nil { return err } // Create a map of pipeline IDs to keep keepMap := make(map[int64]struct{}) for _, p := range pipelinesKeep { keepMap[p.Number] = struct{}{} } // Filter pipelines to only include those not in keepMap var pipelinesToPurge []*woodpecker.Pipeline for _, p := range pipelines { if _, exists := keepMap[p.Number]; !exists { pipelinesToPurge = append(pipelinesToPurge, p) } } msgPrefix := "" if dryRun { msgPrefix = "DRY-RUN: " } for i, p := range pipelinesToPurge { // cspell:words spurge log.Debug().Msgf("%spurge %v/%v pipelines from repo '%v' (pipeline %v)", msgPrefix, i+1, len(pipelinesToPurge), repoIDOrFullName, p.Number) if dryRun { continue } err := client.PipelineDelete(repoID, p.Number) if err != nil { var clientErr *woodpecker.ClientError if errors.As(err, &clientErr) && clientErr.StatusCode == http.StatusUnprocessableEntity { log.Error().Err(err).Msgf("failed to delete pipeline %d", p.Number) continue } return err } } return nil } func fetchPipelinesToKeep(client woodpecker.Client, repoID int64, branch string, keepMin int) ([]*woodpecker.Pipeline, error) { if keepMin <= 0 { return nil, nil } return shared_utils.Paginate(func(page int) ([]*woodpecker.Pipeline, error) { return client.PipelineList(repoID, woodpecker.PipelineListOptions{ ListOptions: woodpecker.ListOptions{ Page: page, }, Branch: branch, }, ) }, keepMin) } func fetchPipelines(client woodpecker.Client, repoID int64, branch string, before time.Time) ([]*woodpecker.Pipeline, error) { return shared_utils.Paginate(func(page int) ([]*woodpecker.Pipeline, error) { return client.PipelineList(repoID, woodpecker.PipelineListOptions{ ListOptions: woodpecker.ListOptions{ Page: page, }, Before: before, Branch: branch, }, ) }, -1) } ================================================ FILE: cli/pipeline/purge_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "io" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks" ) func TestPipelinePurge(t *testing.T) { tests := []struct { name string repoID int64 args []string pipelinesKeep []*woodpecker.Pipeline pipelines []*woodpecker.Pipeline mockDeleteError error wantDelete int wantErr error }{ { name: "success with no pipelines to purge", repoID: 1, args: []string{"purge", "--older-than", "1h", "repo/name"}, pipelinesKeep: []*woodpecker.Pipeline{ {Number: 1}, }, pipelines: []*woodpecker.Pipeline{}, }, { name: "success with pipelines to purge", repoID: 1, args: []string{"purge", "--older-than", "1h", "repo/name"}, pipelinesKeep: []*woodpecker.Pipeline{ {Number: 1}, }, pipelines: []*woodpecker.Pipeline{ {Number: 1}, {Number: 2}, {Number: 3}, }, wantDelete: 2, }, { name: "continue on 422 error", repoID: 1, args: []string{"purge", "--older-than", "1h", "repo/name"}, pipelinesKeep: []*woodpecker.Pipeline{ {Number: 1}, }, pipelines: []*woodpecker.Pipeline{ {Number: 1}, {Number: 2}, {Number: 3}, }, wantDelete: 2, mockDeleteError: &woodpecker.ClientError{ StatusCode: 422, Message: "test error", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := mocks.NewMockClient(t) mockClient.On("RepoLookup", mock.Anything).Maybe().Return(&woodpecker.Repo{ID: tt.repoID}, nil) mockClient.On("PipelineList", mock.Anything, mock.Anything).Return(func(_ int64, opt woodpecker.PipelineListOptions) ([]*woodpecker.Pipeline, error) { // Return keep pipelines for first call if opt.Before.IsZero() { if opt.Page == 1 { return tt.pipelinesKeep, nil } return []*woodpecker.Pipeline{}, nil } // Return pipelines to purge for calls with Before filter if !opt.Before.IsZero() { if opt.Page == 1 { return tt.pipelines, nil } return []*woodpecker.Pipeline{}, nil } return []*woodpecker.Pipeline{}, nil }).Maybe() if tt.mockDeleteError != nil { mockClient.On("PipelineDelete", tt.repoID, mock.Anything).Return(tt.mockDeleteError) } else if tt.wantDelete > 0 { mockClient.On("PipelineDelete", tt.repoID, mock.Anything).Return(nil).Times(tt.wantDelete) } command := pipelinePurgeCmd command.Writer = io.Discard command.Action = func(_ context.Context, c *cli.Command) error { err := pipelinePurge(c, mockClient, time.Unix(1, 1)) if tt.wantErr != nil { assert.EqualError(t, err, tt.wantErr.Error()) return nil } assert.NoError(t, err) return nil } _ = command.Run(t.Context(), tt.args) }) } } ================================================ FILE: cli/pipeline/queue.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "os" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var pipelineQueueCmd = &cli.Command{ Name: "queue", Usage: "show pipeline queue", ArgsUsage: " ", Action: pipelineQueue, Flags: []cli.Flag{common.FormatFlag(tmplPipelineQueue, false)}, } func pipelineQueue(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } pipelines, err := client.PipelineQueue() if err != nil { return err } if len(pipelines) == 0 { fmt.Println("there are no pending or running pipelines") return nil } tmpl, err := template.New("_").Parse(c.String("format") + "\n") if err != nil { return err } for _, pipeline := range pipelines { if err := tmpl.Execute(os.Stdout, pipeline); err != nil { return err } } return nil } // Template for pipeline list information. var tmplPipelineQueue = "\x1b[33m{{ .FullName }} #{{ .Number }} \x1b[0m" + ` Status: {{ .Status }} Event: {{ .Event }} Commit: {{ .Commit }} Branch: {{ .Branch }} Ref: {{ .Ref }} Author: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }} Message: {{ .Message }} ` ================================================ FILE: cli/pipeline/show.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var pipelineShowCmd = &cli.Command{ Name: "show", Usage: "show pipeline information", ArgsUsage: " [pipeline]", Action: pipelineShow, Flags: common.OutputFlags("table"), } func pipelineShow(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } pipelineArg := c.Args().Get(1) var number int64 if pipelineArg == "last" || len(pipelineArg) == 0 { // Fetch the pipeline number from the last pipeline pipeline, err := client.PipelineLast(repoID, woodpecker.PipelineLastOptions{}) if err != nil { return err } number = pipeline.Number } else { number, err = strconv.ParseInt(pipelineArg, 10, 64) if err != nil { return err } } pipeline, err := client.Pipeline(repoID, number) if err != nil { return err } return pipelineOutput(c, []*woodpecker.Pipeline{pipeline}) } ================================================ FILE: cli/pipeline/start.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var pipelineStartCmd = &cli.Command{ Name: "start", Usage: "start a pipeline", ArgsUsage: " [pipeline]", Action: pipelineStart, Flags: []cli.Flag{ &cli.StringSliceFlag{ Name: "param", Aliases: []string{"p"}, Usage: "custom parameters to inject into the step environment. Format: KEY=value", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func pipelineStart(ctx context.Context, c *cli.Command) (err error) { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } pipelineArg := c.Args().Get(1) var number int64 if pipelineArg == "last" { // Fetch the pipeline number from the last pipeline pipeline, err := client.PipelineLast(repoID, woodpecker.PipelineLastOptions{}) if err != nil { return err } number = pipeline.Number } else { if len(pipelineArg) == 0 { return errors.New("missing step number") } number, err = strconv.ParseInt(pipelineArg, 10, 64) if err != nil { return err } } opt := woodpecker.PipelineStartOptions{ Params: internal.ParseKeyPair(c.StringSlice("param")), } pipeline, err := client.PipelineStart(repoID, number, opt) if err != nil { return err } fmt.Printf("Starting pipeline %s#%d\n", repoIDOrFullName, pipeline.Number) return nil } ================================================ FILE: cli/pipeline/stop.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var pipelineStopCmd = &cli.Command{ Name: "stop", Usage: "stop a pipeline", ArgsUsage: " [pipeline]", Action: pipelineStop, } func pipelineStop(ctx context.Context, c *cli.Command) (err error) { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } number, err := strconv.ParseInt(c.Args().Get(1), 10, 64) if err != nil { return err } err = client.PipelineStop(repoID, number) if err != nil { return err } fmt.Printf("Stopping pipeline %s#%d\n", repoIDOrFullName, number) return nil } ================================================ FILE: cli/repo/cron/cron.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "github.com/urfave/cli/v3" ) // Command exports the cron command set. var Command = &cli.Command{ Name: "cron", Usage: "manage cron jobs", Commands: []*cli.Command{ cronCreateCmd, cronDeleteCmd, cronListCmd, cronShowCmd, cronUpdateCmd, }, } ================================================ FILE: cli/repo/cron/cron_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var cronCreateCmd = &cli.Command{ Name: "add", Usage: "add a cron job", ArgsUsage: "[repo-id|repo-full-name]", Action: cronCreate, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "name", Usage: "cron name", Required: true, }, &cli.StringFlag{ Name: "branch", Usage: "cron branch", }, &cli.StringFlag{ Name: "schedule", Usage: "cron schedule", Required: true, }, &cli.BoolFlag{ Name: "enabled", Usage: "whether cron is enabled", Value: true, }, common.FormatFlag(tmplCronList, true), }, } func cronCreate(ctx context.Context, c *cli.Command) error { var ( cronName = c.String("name") branch = c.String("branch") schedule = c.String("schedule") repoIDOrFullName = c.String("repository") format = c.String("format") + "\n" enabled = c.Bool("enabled") ) if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } cron := &woodpecker.Cron{ Name: cronName, Branch: branch, Schedule: schedule, Enabled: enabled, } cron, err = client.CronCreate(repoID, cron) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, cron) } ================================================ FILE: cli/repo/cron/cron_list.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var cronListCmd = &cli.Command{ Name: "ls", Usage: "list cron jobs", ArgsUsage: "[repo-id|repo-full-name]", Action: cronList, Flags: []cli.Flag{ common.RepoFlag, common.FormatFlag(tmplCronList, true), }, } func cronList(ctx context.Context, c *cli.Command) error { var ( format = c.String("format") + "\n" repoIDOrFullName = c.String("repository") ) if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } opt := woodpecker.CronListOptions{} list, err := client.CronList(repoID, opt) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } for _, cron := range list { if err := tmpl.Execute(os.Stdout, cron); err != nil { return err } } return nil } // tTemplate for pipeline list information. var tmplCronList = "\x1b[33m{{ .Name }} \x1b[0m" + ` ID: {{ .ID }} Branch: {{ .Branch }} Schedule: {{ .Schedule }} NextExec: {{ .NextExec }} ` ================================================ FILE: cli/repo/cron/cron_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "context" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var cronDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a cron job", ArgsUsage: "[repo-id|repo-full-name]", Action: cronDelete, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "id", Usage: "cron id", Required: true, }, }, } func cronDelete(ctx context.Context, c *cli.Command) error { var ( cronID = c.Int64("id") repoIDOrFullName = c.String("repository") ) if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } err = client.CronDelete(repoID, cronID) if err != nil { return err } fmt.Println("Success") return nil } ================================================ FILE: cli/repo/cron/cron_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var cronShowCmd = &cli.Command{ Name: "show", Usage: "show cron job information", ArgsUsage: "[repo-id|repo-full-name]", Action: cronShow, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "id", Usage: "cron id", Required: true, }, common.FormatFlag(tmplCronList, true), }, } func cronShow(ctx context.Context, c *cli.Command) error { var ( cronID = c.Int64("id") repoIDOrFullName = c.String("repository") format = c.String("format") + "\n" ) if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } cron, err := client.CronGet(repoID, cronID) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, cron) } ================================================ FILE: cli/repo/cron/cron_update.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var cronUpdateCmd = &cli.Command{ Name: "update", Usage: "update a cron job", ArgsUsage: "[repo-id|repo-full-name]", Action: cronUpdate, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "id", Usage: "cron id", Required: true, }, &cli.StringFlag{ Name: "name", Usage: "cron name", }, &cli.StringFlag{ Name: "branch", Usage: "cron branch", }, &cli.StringFlag{ Name: "schedule", Usage: "cron schedule", }, &cli.BoolFlag{ Name: "enabled", Usage: "whether cron is enabled", Value: true, }, common.FormatFlag(tmplCronList, true), }, } func cronUpdate(ctx context.Context, c *cli.Command) error { var ( repoIDOrFullName = c.String("repository") cronID = c.Int64("id") jobName = c.String("name") branch = c.String("branch") schedule = c.String("schedule") format = c.String("format") + "\n" enabled = c.Bool("enabled") ) if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } cron := &woodpecker.Cron{ ID: cronID, Name: jobName, Branch: branch, Schedule: schedule, Enabled: enabled, } cron, err = client.CronUpdate(repoID, cron) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, cron) } ================================================ FILE: cli/repo/registry/registry.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the registry command set. var Command = &cli.Command{ Name: "registry", Usage: "manage registries", Commands: []*cli.Command{ registryCreateCmd, registryDeleteCmd, registryListCmd, registryShowCmd, registryUpdateCmd, }, } func parseTargetArgs(client woodpecker.Client, c *cli.Command) (repoID int64, err error) { repoIDOrFullName := c.String("repository") if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } return internal.ParseRepo(client, repoIDOrFullName) } ================================================ FILE: cli/repo/registry/registry_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryCreateCmd = &cli.Command{ Name: "add", Usage: "add a registry", ArgsUsage: "[repo-id|repo-full-name]", Action: registryCreate, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, &cli.StringFlag{ Name: "username", Usage: "registry username", }, &cli.StringFlag{ Name: "password", Usage: "registry password", }, }, } func registryCreate(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") username = c.String("username") password = c.String("password") ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry := &woodpecker.Registry{ Address: hostname, Username: username, Password: password, } if strings.HasPrefix(registry.Password, "@") { path := strings.TrimPrefix(registry.Password, "@") out, err := os.ReadFile(path) if err != nil { return err } registry.Password = string(out) } repoID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.RegistryCreate(repoID, registry) return err } ================================================ FILE: cli/repo/registry/registry_list.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryListCmd = &cli.Command{ Name: "ls", Usage: "list registries", ArgsUsage: "[repo-id|repo-full-name]", Action: registryList, Flags: []cli.Flag{ common.RepoFlag, common.FormatFlag(tmplRegistryList, true), }, } func registryList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := parseTargetArgs(client, c) if err != nil { return err } opt := woodpecker.RegistryListOptions{} list, err := client.RegistryList(repoID, opt) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } for _, registry := range list { if err := tmpl.Execute(os.Stdout, registry); err != nil { return err } } return nil } // Template for registry list information. var tmplRegistryList = "\x1b[33m{{ .Address }} \x1b[0m" + ` Username: {{ .Username }} Email: {{ .Email }} ` ================================================ FILE: cli/repo/registry/registry_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var registryDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a registry", ArgsUsage: "[repo-id|repo-full-name]", Action: registryDelete, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, }, } func registryDelete(ctx context.Context, c *cli.Command) error { hostname := c.String("hostname") client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := parseTargetArgs(client, c) if err != nil { return err } return client.RegistryDelete(repoID, hostname) } ================================================ FILE: cli/repo/registry/registry_set.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var registryUpdateCmd = &cli.Command{ Name: "update", Usage: "update a registry", ArgsUsage: "[repo-id|repo-full-name]", Action: registryUpdate, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, &cli.StringFlag{ Name: "username", Usage: "registry username", }, &cli.StringFlag{ Name: "password", Usage: "registry password", }, }, } func registryUpdate(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") username = c.String("username") password = c.String("password") ) client, err := internal.NewClient(ctx, c) if err != nil { return err } registry := &woodpecker.Registry{ Address: hostname, Username: username, Password: password, } if strings.HasPrefix(registry.Password, "@") { path := strings.TrimPrefix(registry.Password, "@") out, err := os.ReadFile(path) if err != nil { return err } registry.Password = string(out) } repoID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.RegistryUpdate(repoID, registry) return err } ================================================ FILE: cli/repo/registry/registry_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var registryShowCmd = &cli.Command{ Name: "show", Usage: "show registry information", ArgsUsage: "[repo-id|repo-full-name]", Action: registryShow, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "hostname", Usage: "registry hostname", Value: "docker.io", }, common.FormatFlag(tmplRegistryList, true), }, } func registryShow(ctx context.Context, c *cli.Command) error { var ( hostname = c.String("hostname") format = c.String("format") + "\n" ) client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := parseTargetArgs(client, c) if err != nil { return err } registry, err := client.Registry(repoID, hostname) if err != nil { return err } tmpl, err := template.New("_").Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, registry) } ================================================ FILE: cli/repo/repo.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "fmt" "io" "os" "text/template" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/output" "go.woodpecker-ci.org/woodpecker/v3/cli/repo/cron" "go.woodpecker-ci.org/woodpecker/v3/cli/repo/registry" "go.woodpecker-ci.org/woodpecker/v3/cli/repo/secret" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the repository command. var Command = &cli.Command{ Name: "repo", Usage: "manage repositories", Commands: []*cli.Command{ repoAddCmd, repoChownCmd, cron.Command, repoListCmd, registry.Command, repoRemoveCmd, repoRepairCmd, secret.Command, repoShowCmd, repoSyncCmd, repoUpdateCmd, }, } func repoOutput(c *cli.Command, repos []*woodpecker.Repo, fd ...io.Writer) error { outFmt, outOpt := output.ParseOutputOptions(c.String("output")) noHeader := c.Bool("output-no-headers") legacyFmt := c.String("format") if legacyFmt != "" { log.Warn().Msgf("the --format flag is deprecated, please use --output instead") outFmt = "go-template" outOpt = []string{fmt.Sprintf("{{range . }}%s{{ print \"\\n\" }}{{end}}", legacyFmt)} } var out io.Writer out = os.Stdout if len(fd) > 0 { out = fd[0] } switch outFmt { case "go-template": if len(outOpt) < 1 { return fmt.Errorf("%w: missing template", output.ErrOutputOptionRequired) } tmpl, err := template.New("_").Parse(outOpt[0] + "\n") if err != nil { return err } if err := tmpl.Execute(out, repos); err != nil { return err } case "go-format": if len(outOpt) < 1 { return fmt.Errorf("%w: missing template", output.ErrOutputOptionRequired) } tmpl, err := template.New("_").Parse(outOpt[0] + "\n") if err != nil { return err } for _, r := range repos { if err := tmpl.Execute(out, r); err != nil { return err } } case "table": fallthrough default: table := output.NewTable(out) // Add custom field mapping for nested Trusted fields table.AddFieldFn("TrustedNetwork", func(obj any) string { repo, ok := obj.(*woodpecker.Repo) if !ok { return "" } return output.YesNo(repo.Trusted.Network) }) table.AddFieldFn("TrustedSecurity", func(obj any) string { repo, ok := obj.(*woodpecker.Repo) if !ok { return "" } return output.YesNo(repo.Trusted.Security) }) table.AddFieldFn("TrustedVolume", func(obj any) string { repo, ok := obj.(*woodpecker.Repo) if !ok { return "" } return output.YesNo(repo.Trusted.Volumes) }) table.AddFieldAlias("Is_Active", "Active") table.AddFieldAlias("Is_SCM_Private", "SCM_Private") cols := []string{"Full_Name", "Branch", "Forge_URL", "Visibility", "SCM_Private", "Active", "Allow_Pull"} if len(outOpt) > 0 { cols = outOpt } if !noHeader { table.WriteHeader(cols) } for _, resource := range repos { if err := table.Write(cols, resource); err != nil { return err } } table.Flush() } return nil } ================================================ FILE: cli/repo/repo_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "fmt" "strconv" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var repoAddCmd = &cli.Command{ Name: "add", Usage: "add a repository", ArgsUsage: "", Action: repoAdd, } func repoAdd(ctx context.Context, c *cli.Command) error { _forgeRemoteID := c.Args().First() forgeRemoteID, err := strconv.Atoi(_forgeRemoteID) if err != nil { return fmt.Errorf("invalid forge remote id: %s", _forgeRemoteID) } client, err := internal.NewClient(ctx, c) if err != nil { return err } opt := woodpecker.RepoPostOptions{ ForgeRemoteID: int64(forgeRemoteID), } repo, err := client.RepoPost(opt) if err != nil { return err } fmt.Printf("Successfully activated repository with forge remote %s\n", repo.FullName) return nil } ================================================ FILE: cli/repo/repo_chown.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var repoChownCmd = &cli.Command{ Name: "chown", Usage: "assume ownership of a repository", ArgsUsage: "", Action: repoChown, } func repoChown(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } repo, err := client.RepoChown(repoID) if err != nil { return err } fmt.Printf("Successfully assumed ownership of repository %s\n", repo.FullName) return nil } ================================================ FILE: cli/repo/repo_list.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var repoListCmd = &cli.Command{ Name: "ls", Usage: "list all repos", ArgsUsage: " ", Action: List, Flags: append(common.OutputFlags("table"), []cli.Flag{ common.FormatFlag("", true), &cli.StringFlag{ Name: "org", Usage: "filter by organization", }, &cli.BoolFlag{ Name: "all", Usage: "query all repos, including inactive ones", }, }...), } func List(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } repos, err := repoList(c, client) if err != nil { return err } return repoOutput(c, repos) } func repoList(c *cli.Command, client woodpecker.Client) ([]*woodpecker.Repo, error) { repos := make([]*woodpecker.Repo, 0) opt := woodpecker.RepoListOptions{ All: c.Bool("all"), } raw, err := client.RepoList(opt) if err != nil || len(raw) == 0 { return nil, err } org := c.String("org") for _, repo := range raw { if org != "" && org != repo.Owner { continue } repos = append(repos, repo) } return repos, nil } ================================================ FILE: cli/repo/repo_repair.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var repoRepairCmd = &cli.Command{ Name: "repair", Usage: "repair repository webhooks", ArgsUsage: "", Action: repoRepair, } func repoRepair(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } if err := client.RepoRepair(repoID); err != nil { return err } fmt.Printf("Successfully repaired repository %s\n", repoIDOrFullName) return nil } ================================================ FILE: cli/repo/repo_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var repoRemoveCmd = &cli.Command{ Name: "rm", Usage: "remove a repository", ArgsUsage: "", Action: repoRemove, } func repoRemove(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } if err := client.RepoDel(repoID); err != nil { return err } fmt.Printf("Successfully removed repository %s\n", repoIDOrFullName) return nil } ================================================ FILE: cli/repo/repo_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var repoShowCmd = &cli.Command{ Name: "show", Usage: "show repository information", ArgsUsage: "", Action: Show, Flags: common.OutputFlags("table"), } func Show(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } repo, err := repoShow(c, client) if err != nil { return err } return repoOutput(c, []*woodpecker.Repo{repo}) } func repoShow(c *cli.Command, client woodpecker.Client) (*woodpecker.Repo, error) { repoIDOrFullName := c.Args().First() repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return nil, err } repo, err := client.Repo(repoID) if err != nil { return nil, err } return repo, nil } ================================================ FILE: cli/repo/repo_show_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "errors" "io" "testing" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker/mocks" ) func TestRepoShow(t *testing.T) { tests := []struct { name string repoID int64 mockRepo *woodpecker.Repo mockError error expectedError bool expected *woodpecker.Repo args []string }{ { name: "valid repo by ID", repoID: 123, mockRepo: &woodpecker.Repo{Name: "test-repo"}, expected: &woodpecker.Repo{Name: "test-repo"}, args: []string{"show", "123"}, }, { name: "valid repo by full name", repoID: 456, mockRepo: &woodpecker.Repo{ID: 456, Name: "repo", Owner: "owner"}, expected: &woodpecker.Repo{ID: 456, Name: "repo", Owner: "owner"}, args: []string{"show", "owner/repo"}, }, { name: "invalid repo ID", repoID: 999, expectedError: true, args: []string{"show", "invalid"}, mockError: errors.New("repo not found"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockClient := mocks.NewMockClient(t) mockClient.On("Repo", tt.repoID).Return(tt.mockRepo, tt.mockError).Maybe() mockClient.On("RepoLookup", "owner/repo").Return(tt.mockRepo, nil).Maybe() command := repoShowCmd command.Writer = io.Discard command.Action = func(_ context.Context, c *cli.Command) error { output, err := repoShow(c, mockClient) if tt.expectedError { assert.Error(t, err) return nil } assert.NoError(t, err) assert.Equal(t, tt.expected, output) return nil } _ = command.Run(t.Context(), tt.args) }) } } ================================================ FILE: cli/repo/repo_sync.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "os" "text/template" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var repoSyncCmd = &cli.Command{ Name: "sync", Usage: "synchronize the repository list", ArgsUsage: " ", Action: repoSync, Flags: []cli.Flag{common.FormatFlag(tmplRepoList, false)}, } // TODO: remove this and add an option to the list cmd as we do not store the remote repo list anymore func repoSync(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } opt := woodpecker.RepoListOptions{ All: true, } repos, err := client.RepoList(opt) if err != nil || len(repos) == 0 { return err } tmpl, err := template.New("_").Parse(c.String("format") + "\n") if err != nil { return err } org := c.String("org") for _, repo := range repos { if org != "" && org != repo.Owner { continue } if err := tmpl.Execute(os.Stdout, repo); err != nil { return err } } return nil } // Template for repository list items. var tmplRepoList = "\x1b[33m{{ .FullName }}\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})" ================================================ FILE: cli/repo/repo_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "bytes" "context" "io" "testing" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) func TestRepoOutput(t *testing.T) { tests := []struct { name string args []string expected string wantErr bool }{ { name: "table output with default columns", args: []string{}, expected: "FULL NAME BRANCH FORGE URL VISIBILITY SCM PRIVATE ACTIVE ALLOW PULL\norg/repo1 main git.example.com public no yes yes\n", }, { name: "table output with custom columns", args: []string{"output", "--output", "table=Name,Forge_URL,Trusted_Network"}, expected: "NAME FORGE URL TRUSTED NETWORK\nrepo1 git.example.com yes\n", }, { name: "table output with no header", args: []string{"output", "--output-no-headers"}, expected: "org/repo1 main git.example.com public no yes yes\n", }, { name: "go-template output", args: []string{"output", "--output", "go-template={{range . }}{{.Name}} {{.ForgeURL}} {{.Trusted.Network}}{{end}}"}, expected: "repo1 git.example.com true\n", }, { name: "go-format output", args: []string{"output", "--output", "go-format={{.Name}} {{.ForgeURL}} {{.Trusted.Network}}"}, expected: "repo1 git.example.com true\n", }, { name: "invalid go-template", args: []string{"output", "--output", "go-template={{.InvalidField}}"}, wantErr: true, }, } repos := []*woodpecker.Repo{ { Name: "repo1", FullName: "org/repo1", ForgeURL: "git.example.com", Branch: "main", Visibility: "public", IsActive: true, AllowPull: true, Trusted: woodpecker.TrustedConfiguration{ Network: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { command := &cli.Command{ Writer: io.Discard, Name: "output", Flags: common.OutputFlags("table"), Action: func(_ context.Context, c *cli.Command) error { var buf bytes.Buffer err := repoOutput(c, repos, &buf) if tt.wantErr { assert.Error(t, err) return nil } assert.NoError(t, err) assert.Equal(t, tt.expected, buf.String()) return nil }, } _ = command.Run(t.Context(), tt.args) }) } } ================================================ FILE: cli/repo/repo_update.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package repo import ( "context" "fmt" "time" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var repoUpdateCmd = &cli.Command{ Name: "update", Usage: "update a repository", ArgsUsage: "", Action: repoUpdate, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "trusted-security", Usage: "repository is security trusted", }, &cli.BoolFlag{ Name: "trusted-volumes", Usage: "repository is volumes trusted", }, &cli.BoolFlag{ Name: "trusted-network", Usage: "repository is network trusted", }, &cli.BoolFlag{ Name: "trusted", // TODO: remove in next release Usage: "repository is trusted", Hidden: true, }, &cli.BoolFlag{ Name: "gated", // TODO: remove in next release Hidden: true, }, &cli.StringFlag{ Name: "require-approval", Usage: "repository requires approval for", }, &cli.DurationFlag{ Name: "timeout", Usage: "repository timeout", }, &cli.StringFlag{ Name: "visibility", Usage: "repository visibility", }, &cli.StringFlag{ Name: "config", Usage: "repository configuration path. Example: .woodpecker.yml", }, &cli.IntFlag{ Name: "pipeline-counter", Usage: "repository starting pipeline number", }, &cli.BoolFlag{ Name: "unsafe", Usage: "allow unsafe operations", }, }, } func repoUpdate(ctx context.Context, c *cli.Command) error { repoIDOrFullName := c.Args().First() client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := internal.ParseRepo(client, repoIDOrFullName) if err != nil { return err } var ( visibility = c.String("visibility") config = c.String("config") timeout = c.Duration("timeout") requireApproval = c.String("require-approval") pipelineCounter = c.Int("pipeline-counter") unsafe = c.Bool("unsafe") ) patch := new(woodpecker.RepoPatch) // TODO remove in next release if c.IsSet("trusted") { trusted := c.Bool("trusted") patch.Trusted = &woodpecker.TrustedConfigurationPatch{ Network: &trusted, Security: &trusted, Volumes: &trusted, } } if c.IsSet("trusted-security") || c.IsSet("trusted-network") || c.IsSet("trusted-volumes") { patch.Trusted = new(woodpecker.TrustedConfigurationPatch) if c.IsSet("trusted-security") { t := c.Bool("trusted-security") patch.Trusted.Security = &t } if c.IsSet("trusted-network") { t := c.Bool("trusted-network") patch.Trusted.Network = &t } if c.IsSet("trusted-volumes") { t := c.Bool("trusted-volumes") patch.Trusted.Volumes = &t } } // TODO: remove in next release if c.IsSet("gated") { return fmt.Errorf("'gated' option has been set in version 2.8, use 'require-approval' in >= 3.0") } if c.IsSet("require-approval") { if mode := woodpecker.ApprovalMode(requireApproval); mode.Valid() { patch.RequireApproval = &mode } else { return fmt.Errorf("update approval mode failed: '%s' is no valid mode", mode) } } if c.IsSet("timeout") { v := int64(timeout / time.Minute) patch.Timeout = &v } if c.IsSet("config") { patch.Config = &config } if c.IsSet("visibility") { switch visibility { case "public", "private", "internal": patch.Visibility = &visibility } } if c.IsSet("pipeline-counter") && !unsafe { fmt.Printf("Setting the pipeline counter is an unsafe operation that could put your repository in an inconsistent state. Please use --unsafe to proceed") } if c.IsSet("pipeline-counter") && unsafe { patch.PipelineCounter = &pipelineCounter } repo, err := client.RepoPatch(repoID, patch) if err != nil { return err } fmt.Printf("Successfully updated repository %s\n", repo.FullName) return nil } ================================================ FILE: cli/repo/secret/secret.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) // Command exports the secret command. var Command = &cli.Command{ Name: "secret", Usage: "manage secrets", Commands: []*cli.Command{ secretCreateCmd, secretDeleteCmd, secretListCmd, secretShowCmd, secretUpdateCmd, }, } func parseTargetArgs(client woodpecker.Client, c *cli.Command) (repoID int64, err error) { repoIDOrFullName := c.String("repository") if repoIDOrFullName == "" { repoIDOrFullName = c.Args().First() } return internal.ParseRepo(client, repoIDOrFullName) } ================================================ FILE: cli/repo/secret/secret_add.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretCreateCmd = &cli.Command{ Name: "add", Usage: "add a secret", ArgsUsage: "[repo-id|repo-full-name]", Action: secretCreate, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, &cli.StringFlag{ Name: "value", Usage: "secret value", }, &cli.StringSliceFlag{ Name: "event", Usage: "limit secret to these events", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Name: "image", Usage: "limit secret to these images", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func secretCreate(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } secret := &woodpecker.Secret{ Name: strings.ToLower(c.String("name")), Value: c.String("value"), Images: c.StringSlice("image"), Events: c.StringSlice("event"), } if len(secret.Events) == 0 { secret.Events = defaultSecretEvents } if strings.HasPrefix(secret.Value, "@") { path := strings.TrimPrefix(secret.Value, "@") out, err := os.ReadFile(path) if err != nil { return err } secret.Value = string(out) } repoID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.SecretCreate(repoID, secret) return err } var defaultSecretEvents = []string{ woodpecker.EventPush, woodpecker.EventTag, woodpecker.EventRelease, woodpecker.EventDeploy, } ================================================ FILE: cli/repo/secret/secret_list.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "html/template" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretListCmd = &cli.Command{ Name: "ls", Usage: "list secrets", ArgsUsage: "[repo-id|repo-full-name]", Action: secretList, Flags: []cli.Flag{ common.RepoFlag, common.FormatFlag(tmplSecretList, true), }, } func secretList(ctx context.Context, c *cli.Command) error { format := c.String("format") + "\n" client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := parseTargetArgs(client, c) if err != nil { return err } opt := woodpecker.SecretListOptions{} list, err := client.SecretList(repoID, opt) if err != nil { return err } tmpl, err := template.New("_").Funcs(secretFuncMap).Parse(format) if err != nil { return err } for _, secret := range list { if err := tmpl.Execute(os.Stdout, secret); err != nil { return err } } return nil } // Template for secret list items. var tmplSecretList = "\x1b[33m{{ .Name }} \x1b[0m" + ` Events: {{ list .Events }} {{- if .Images }} Images: {{ list .Images }} {{- else }} Images: {{- end }} ` var secretFuncMap = template.FuncMap{ "list": func(s []string) string { return strings.Join(s, ", ") }, } ================================================ FILE: cli/repo/secret/secret_rm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var secretDeleteCmd = &cli.Command{ Name: "rm", Usage: "remove a secret", ArgsUsage: "[repo-id|repo-full-name]", Action: secretDelete, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, }, } func secretDelete(ctx context.Context, c *cli.Command) error { secretName := c.String("name") client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := parseTargetArgs(client, c) if err != nil { return err } return client.SecretDelete(repoID, secretName) } ================================================ FILE: cli/repo/secret/secret_set.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "os" "strings" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" ) var secretUpdateCmd = &cli.Command{ Name: "update", Usage: "update a secret", ArgsUsage: "[repo-id|repo-full-name]", Action: secretUpdate, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, &cli.StringFlag{ Name: "value", Usage: "secret value", }, &cli.StringSliceFlag{ Name: "event", Usage: "limit secret to these events", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Name: "image", Usage: "limit secret to these images", Config: cli.StringConfig{ TrimSpace: true, }, }, }, } func secretUpdate(ctx context.Context, c *cli.Command) error { client, err := internal.NewClient(ctx, c) if err != nil { return err } secret := &woodpecker.Secret{ Name: strings.ToLower(c.String("name")), Value: c.String("value"), Images: c.StringSlice("image"), Events: c.StringSlice("event"), } if strings.HasPrefix(secret.Value, "@") { path := strings.TrimPrefix(secret.Value, "@") out, err := os.ReadFile(path) if err != nil { return err } secret.Value = string(out) } repoID, err := parseTargetArgs(client, c) if err != nil { return err } _, err = client.SecretUpdate(repoID, secret) return err } ================================================ FILE: cli/repo/secret/secret_show.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "fmt" "html/template" "os" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/internal" ) var secretShowCmd = &cli.Command{ Name: "show", Usage: "show secret information", ArgsUsage: "[repo-id|repo-full-name]", Action: secretShow, Flags: []cli.Flag{ common.RepoFlag, &cli.StringFlag{ Name: "name", Usage: "secret name", }, common.FormatFlag(tmplSecretList, true), }, } func secretShow(ctx context.Context, c *cli.Command) error { var ( secretName = c.String("name") format = c.String("format") + "\n" ) if secretName == "" { return fmt.Errorf("secret name is missing") } client, err := internal.NewClient(ctx, c) if err != nil { return err } repoID, err := parseTargetArgs(client, c) if err != nil { return err } secret, err := client.Secret(repoID, secretName) if err != nil { return err } tmpl, err := template.New("_").Funcs(secretFuncMap).Parse(format) if err != nil { return err } return tmpl.Execute(os.Stdout, secret) } ================================================ FILE: cli/setup/setup.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package setup import ( "context" "errors" "fmt" "strings" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/internal/config" "go.woodpecker-ci.org/woodpecker/v3/cli/setup/ui" ) // Command exports the setup command. var Command = &cli.Command{ Name: "setup", Usage: "setup the woodpecker-cli for the first time", ArgsUsage: "[server]", Flags: []cli.Flag{ &cli.StringFlag{ Name: "server", Usage: "URL of the woodpecker server", }, &cli.StringFlag{ Name: "token", Usage: "token to authenticate with the woodpecker server", }, &cli.StringFlag{ Name: "context", Aliases: []string{"ctx"}, Usage: "name for the context (defaults to 'default')", }, }, Action: setup, } func setup(ctx context.Context, c *cli.Command) error { contextName := c.String("context") if contextName == "" { contextName = "default" } // Check if context already exists contexts, err := config.LoadContexts() if err != nil { return err } if existingCtx, exists := contexts.Contexts[contextName]; exists { setupAgain, err := ui.Confirm(fmt.Sprintf("Context '%s' already exists (server: %s). Do you want to reconfigure it?", contextName, existingCtx.ServerURL)) if err != nil { return err } if !setupAgain { log.Info().Msg("configuration skipped") return nil } } serverURL := c.String("server") if serverURL == "" { serverURL = c.Args().First() } if serverURL == "" { serverURL, err = ui.Ask("Enter the URL of the woodpecker server", "https://ci.woodpecker-ci.org", true) if err != nil { return err } if serverURL == "" { return errors.New("server URL cannot be empty") } } if !strings.Contains(serverURL, "://") { serverURL = "https://" + serverURL } token := c.String("token") if token == "" { token, err = receiveTokenFromUI(ctx, serverURL) if err != nil { return err } if token == "" { return errors.New("no token received from the UI") } } // Save as context err = config.AddOrUpdateContext(c, contextName, serverURL, token, "info", true) if err != nil { return err } log.Info().Msgf("Context '%s' has been successfully created and set as current", contextName) return nil } ================================================ FILE: cli/setup/token_fetcher.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package setup import ( "context" "errors" "fmt" "math/rand" "net/http" "os/exec" "runtime" "time" "charm.land/huh/v2/spinner" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" ) func receiveTokenFromUI(c context.Context, serverURL string) (string, error) { port := randomPort() tokenReceived := make(chan string) srv := &http.Server{Addr: fmt.Sprintf("127.0.0.1:%d", port)} srv.Handler = setupRouter(tokenReceived) go func() { log.Debug().Msgf("listening for token response on :%d", port) _ = srv.ListenAndServe() }() defer func() { log.Debug().Msg("shutting down server") _ = srv.Shutdown(c) }() err := openBrowser(fmt.Sprintf("%s/cli/auth?port=%d", serverURL, port)) if err != nil { return "", err } spinnerCtx, spinnerDone := context.WithCancelCause(c) go func() { err = spinner.New(). Title("Waiting for token ..."). Context(spinnerCtx). Run() if err != nil { return } }() // wait for token to be received or timeout select { case token := <-tokenReceived: spinnerDone(nil) return token, nil case <-c.Done(): spinnerDone(nil) return "", c.Err() case <-time.After(5 * time.Minute): spinnerDone(nil) return "", errors.New("timed out waiting for token") } } func setupRouter(tokenReceived chan string) *gin.Engine { gin.SetMode(gin.ReleaseMode) e := gin.New() e.UseRawPath = true e.Use(gin.Recovery()) e.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) e.POST("/token", func(c *gin.Context) { data := struct { Token string `json:"token"` }{} err := c.BindJSON(&data) if err != nil { log.Debug().Err(err).Msg("failed to bind JSON") c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid request", }) return } tokenReceived <- data.Token c.JSON(http.StatusOK, gin.H{ "ok": "true", }) }) return e } func openBrowser(url string) error { var err error log.Debug().Msgf("opening browser with URL: %s", url) switch runtime.GOOS { case "linux": err = exec.Command("xdg-open", url).Start() case "windows": err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() case "darwin": err = exec.Command("open", url).Start() default: err = fmt.Errorf("unsupported platform") } return err } func randomPort() int { const minPort = 10000 const maxPort = 65535 source := rand.NewSource(time.Now().UnixNano()) rand := rand.New(source) return rand.Intn(maxPort-minPort+1) + minPort } ================================================ FILE: cli/setup/ui/ask.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ui import ( "errors" "strings" "charm.land/huh/v2" ) func Ask(prompt, placeholder string, required bool) (string, error) { var input string err := huh.NewInput(). Title(prompt). Value(&input). Placeholder(placeholder).Validate(func(s string) error { if required && strings.TrimSpace(s) == "" { return errors.New("required") } return nil }).Run() if err != nil { return "", err } return strings.TrimSpace(input), nil } ================================================ FILE: cli/setup/ui/confirm.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ui import ( "charm.land/huh/v2" ) func Confirm(prompt string) (bool, error) { var confirm bool err := huh.NewConfirm(). Title(prompt). Affirmative("Yes!"). Negative("No."). Value(&confirm).Run() if err != nil { return false, err } return confirm, err } ================================================ FILE: cli/update/command.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package update import ( "context" "fmt" "os" "path/filepath" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" ) // Command exports the update command. var Command = &cli.Command{ Name: "update", Usage: "update the woodpecker-cli to the latest version", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "force", Usage: "force update even if the latest version is already installed", }, }, Action: update, } func update(ctx context.Context, c *cli.Command) error { log.Info().Msg("checking for updates ...") newVersion, err := CheckForUpdate(ctx, c.Bool("force")) if err != nil { return err } if newVersion == nil { fmt.Println("you are using the latest version of woodpecker-cli") return nil } log.Info().Msgf("new version %s is available! Updating ...", newVersion.Version) var tarFilePath string tarFilePath, err = downloadNewVersion(ctx, newVersion.AssetURL) if err != nil { return err } log.Debug().Msgf("new version %s has been downloaded successfully! Installing ...", newVersion.Version) binFile, err := extractNewVersion(tarFilePath) if err != nil { return err } log.Debug().Msgf("new version %s has been extracted to %s", newVersion.Version, binFile) executablePathOrSymlink, err := os.Executable() if err != nil { return err } executablePath, err := filepath.EvalSymlinks(executablePathOrSymlink) if err != nil { return err } if err := os.Rename(binFile, executablePath); err != nil { return err } log.Info().Msgf("woodpecker-cli has been updated to version %s successfully!", newVersion.Version) return nil } ================================================ FILE: cli/update/tar.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package update import ( "archive/tar" "compress/gzip" "io" "io/fs" "os" "path/filepath" ) const tarDirectoryMode fs.FileMode = 0x755 func UnTar(dst string, r io.Reader) error { gzr, err := gzip.NewReader(r) if err != nil { return err } defer gzr.Close() tr := tar.NewReader(gzr) for { header, err := tr.Next() switch { case err == io.EOF: return nil case err != nil: return err case header == nil: continue } target := filepath.Join(dst, header.Name) switch header.Typeflag { case tar.TypeDir: if _, err := os.Stat(target); err != nil { if err := os.MkdirAll(target, tarDirectoryMode); err != nil { return err } } case tar.TypeReg: f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) if err != nil { return err } if _, err := io.Copy(f, tr); err != nil { return err } f.Close() } } } ================================================ FILE: cli/update/types.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package update type VersionData struct { Latest string `json:"latest"` Next string `json:"next"` RC string `json:"rc"` } type NewVersion struct { Version string AssetURL string } const ( woodpeckerVersionURL = "https://woodpecker-ci.org/version.json" githubBinaryURL = "https://github.com/woodpecker-ci/woodpecker/releases/download/v%s/woodpecker-cli_%s_%s.tar.gz" ) ================================================ FILE: cli/update/updater.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package update import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path" "runtime" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/version" ) func CheckForUpdate(ctx context.Context, force bool) (*NewVersion, error) { return checkForUpdate(ctx, woodpeckerVersionURL, force) } func checkForUpdate(ctx context.Context, versionURL string, force bool) (*NewVersion, error) { log.Debug().Msgf("current version: %s", version.String()) if (version.String() == "dev" || strings.HasPrefix(version.String(), "next-")) && !force { log.Debug().Msgf("skipping update check for development/next versions") return nil, nil } req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("failed to fetch the latest release") } var versionData VersionData if err := json.NewDecoder(resp.Body).Decode(&versionData); err != nil { return nil, err } upstreamVersion := versionData.Latest if strings.HasPrefix(version.String(), "next-") { upstreamVersion = versionData.Next } else if strings.HasSuffix(version.String(), "rc-") { upstreamVersion = versionData.RC } installedVersion := strings.TrimPrefix(version.Version, "v") upstreamVersion = strings.TrimPrefix(upstreamVersion, "v") // using the latest release if installedVersion == upstreamVersion && !force { log.Debug().Msgf("no new version available") return nil, nil } log.Debug().Msgf("new version available: %s", upstreamVersion) assetURL := fmt.Sprintf(githubBinaryURL, upstreamVersion, runtime.GOOS, runtime.GOARCH) return &NewVersion{ Version: upstreamVersion, AssetURL: assetURL, }, nil } func downloadNewVersion(ctx context.Context, downloadURL string) (string, error) { log.Debug().Msgf("downloading new version from %s ...", downloadURL) req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errors.New("failed to download the new version") } file, err := os.CreateTemp("", "woodpecker-cli-*.tar.gz") if err != nil { return "", err } defer file.Close() if _, err := io.Copy(file, resp.Body); err != nil { return "", err } log.Debug().Msgf("new version downloaded to %s", file.Name()) return file.Name(), nil } func extractNewVersion(tarFilePath string) (string, error) { log.Debug().Msgf("extracting new version from %s ...", tarFilePath) tarFile, err := os.Open(tarFilePath) if err != nil { return "", err } defer tarFile.Close() tmpDir, err := os.MkdirTemp("", "woodpecker-cli-*") if err != nil { return "", err } err = UnTar(tmpDir, tarFile) if err != nil { return "", err } err = os.Remove(tarFilePath) if err != nil { return "", err } log.Debug().Msgf("new version extracted to %s", tmpDir) return path.Join(tmpDir, "woodpecker-cli"), nil } ================================================ FILE: cli/update/updater_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package update import ( "io" "net/http" "net/http/httptest" "os" "testing" "go.woodpecker-ci.org/woodpecker/v3/version" ) func TestCheckForUpdate(t *testing.T) { version.Version = "1.0.0" fixtureHandler := func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/version.json" { http.NotFound(w, r) return } _, _ = io.WriteString(w, `{"latest": "1.0.1", "next": "1.0.2", "rc": "1.0.3"}`) } ts := httptest.NewServer(http.HandlerFunc(fixtureHandler)) defer ts.Close() newVersion, err := checkForUpdate(t.Context(), ts.URL+"/version.json", false) if err != nil { t.Fatalf("Failed to check for updates: %v", err) } if newVersion == nil || newVersion.Version != "1.0.1" { t.Fatalf("Expected a new version 1.0.1, got: %s", newVersion) } } func TestDownloadNewVersion(t *testing.T) { downloadFilePath := "/woodpecker-cli_linux_amd64.tar.gz" fixtureHandler := func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != downloadFilePath { http.NotFound(w, r) return } _, _ = io.WriteString(w, `blob`) } ts := httptest.NewServer(http.HandlerFunc(fixtureHandler)) defer ts.Close() file, err := downloadNewVersion(t.Context(), ts.URL+downloadFilePath) if err != nil { t.Fatalf("Failed to download new version: %v", err) } if file == "" { t.Fatalf("Expected a file path, got: %s", file) } _ = os.Remove(file) } ================================================ FILE: cmd/agent/core/agent.go ================================================ // Copyright 2023 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "context" "crypto/tls" "errors" "fmt" "maps" "net/http" "os" "strings" "sync/atomic" "time" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "golang.org/x/sync/errgroup" "google.golang.org/grpc" "google.golang.org/grpc/codes" grpc_credentials "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "go.woodpecker-ci.org/woodpecker/v3/agent" agent_rpc "go.woodpecker-ci.org/woodpecker/v3/agent/rpc" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" "go.woodpecker-ci.org/woodpecker/v3/version" ) const ( reportHealthInterval = time.Second * 10 authInterceptorRefreshInterval = time.Minute * 30 ) func run(ctx context.Context, c *cli.Command, backends []types.Backend) error { log.Info().Str("version", version.String()).Msg("Starting Woodpecker agent") agentCtx, ctxCancel := context.WithCancelCause(ctx) defer func() { log.Info().Msg("shutdown of whole agent") ctxCancel(nil) }() serviceWaitingGroup := errgroup.Group{} agentConfigPath := c.String("agent-config") hostname := c.String("hostname") if len(hostname) == 0 { hostname, _ = os.Hostname() } maxWorkflows := c.Int("max-workflows") singleWorkflow := c.Bool("single-workflow") if singleWorkflow && maxWorkflows > 1 { log.Warn().Msgf("max-workflows forced from %d to 1 due to agent running single workflow mode.", maxWorkflows) maxWorkflows = 1 } counter.Polling = maxWorkflows counter.Running = 0 if c.Bool("healthcheck") { serviceWaitingGroup.Go( func() error { server := &http.Server{Addr: c.String("healthcheck-addr")} go func() { <-agentCtx.Done() log.Info().Msg("shutdown healthcheck server ...") shutdownCtx, shutdownCtxCancel := agent.GetShutdownContext() defer shutdownCtxCancel() if err := server.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck log.Error().Err(err).Msg("shutdown healthcheck server failed") } else { log.Info().Msg("healthcheck server stopped") } }() if err := server.ListenAndServe(); err != nil { log.Error().Err(err).Msgf("cannot listen on address %s", c.String("healthcheck-addr")) } return nil }) } var transport grpc.DialOption if c.Bool("grpc-secure") { log.Trace().Msg("use ssl for grpc") transport = grpc.WithTransportCredentials(grpc_credentials.NewTLS(&tls.Config{InsecureSkipVerify: c.Bool("grpc-skip-insecure")})) } else { transport = grpc.WithTransportCredentials(insecure.NewCredentials()) } authConn, err := grpc.NewClient( c.String("server"), transport, grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: c.Duration("grpc-keepalive-time"), Timeout: c.Duration("grpc-keepalive-timeout"), }), ) if err != nil { return fmt.Errorf("could not create new gRPC 'channel' for authentication: %w", err) } defer authConn.Close() agentConfig := readAgentConfig(agentConfigPath) agentToken := c.String("grpc-token") grpcClientCtx, grpcClientCtxCancel := context.WithCancelCause(context.Background()) defer grpcClientCtxCancel(nil) authClient := agent_rpc.NewAuthGrpcClient(authConn, agentToken, agentConfig.AgentID) authInterceptor, err := agent_rpc.NewAuthInterceptor(grpcClientCtx, authClient, authInterceptorRefreshInterval) //nolint:contextcheck if err != nil { return fmt.Errorf("agent could not auth: %w", err) } // Persist the agent ID received during auth so that crashloops reuse the // same server-side entry instead of creating a new one on every restart. if agentConfigPath != "" { agentConfig.AgentID = authClient.AgentID() if err := writeAgentConfig(agentConfig, agentConfigPath); err == nil { log.Debug().Msgf("persisted agent ID %d after auth", agentConfig.AgentID) } } conn, err := grpc.NewClient( c.String("server"), transport, grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: c.Duration("grpc-keepalive-time"), Timeout: c.Duration("grpc-keepalive-timeout"), }), grpc.WithUnaryInterceptor(authInterceptor.Unary()), grpc.WithStreamInterceptor(authInterceptor.Stream()), ) if err != nil { return fmt.Errorf("could not create new gRPC 'channel' for normal orchestration: %w", err) } defer conn.Close() client := agent_rpc.NewGrpcClient(ctx, conn, agent_rpc.SetConnectionRetryTimeout(c.Duration("retry-timeout")), ) agentConfigPersisted := atomic.Bool{} grpcCtx := metadata.NewOutgoingContext(grpcClientCtx, metadata.Pairs("hostname", hostname)) // check if grpc server version is compatible with agent grpcServerVersion, err := client.Version(grpcCtx) //nolint:contextcheck if err != nil { log.Error().Err(err).Msg("could not get grpc server version") return err } if grpcServerVersion.GrpcVersion != agent_rpc.ClientGrpcVersion { err := errors.New("GRPC version mismatch") log.Error().Err(err).Msgf("server version %s does report grpc version %d but we only understand %d", grpcServerVersion.ServerVersion, grpcServerVersion.GrpcVersion, agent_rpc.ClientGrpcVersion) return err } // new engine backendCtx := context.WithValue(agentCtx, types.CliCommand, c) backendName := c.String("backend-engine") backendEngine, err := backend.FindBackend(backendCtx, backends, backendName) if err != nil { log.Error().Err(err).Msgf("cannot find backend engine '%s'", backendName) return err } if !backendEngine.IsAvailable(backendCtx) { log.Error().Str("engine", backendEngine.Name()).Msg("selected backend engine is unavailable") return fmt.Errorf("selected backend engine %s is unavailable", backendEngine.Name()) } // load engine (e.g. init api client) engInfo, err := backendEngine.Load(backendCtx) if err != nil { log.Error().Err(err).Msg("cannot load backend engine") return err } log.Debug().Msgf("loaded %s backend engine", backendEngine.Name()) customLabels := make(map[string]string) if err := stringSliceAddToMap(c.StringSlice("labels"), customLabels); err != nil { return err } if len(customLabels) != 0 { log.Debug().Msgf("custom labels detected: %#v", customLabels) } agentConfig.AgentID, err = client.RegisterAgent(grpcCtx, rpc.AgentInfo{ //nolint:contextcheck Version: version.String(), Backend: backendEngine.Name(), Platform: engInfo.Platform, Capacity: maxWorkflows, CustomLabels: customLabels, }) if err != nil { return err } serviceWaitingGroup.Go(func() error { // we close grpc client context once unregister was handled defer grpcClientCtxCancel(nil) // we wait till agent context is done <-agentCtx.Done() // Remove stateless agents from server if !agentConfigPersisted.Load() { if client.IsConnected() { log.Debug().Msg("unregister agent from server ...") err := client.UnregisterAgent(grpcClientCtx) if err != nil { log.Err(err).Msg("failed to unregister agent from server") } else { log.Info().Msg("agent unregistered from server") } } else { log.Debug().Msg("skipping unregister: server not connected") } } return nil }) if agentConfigPath != "" { if err := writeAgentConfig(agentConfig, agentConfigPath); err == nil { agentConfigPersisted.Store(true) } } // set default labels ... labels := make(map[string]string) labels[pipeline.LabelFilterHostname] = hostname labels[pipeline.LabelFilterPlatform] = engInfo.Platform labels[pipeline.LabelFilterBackend] = backendEngine.Name() labels[pipeline.LabelFilterRepo] = "*" // allow all repos by default // ... and let it overwrite by custom ones maps.Copy(labels, customLabels) log.Debug().Any("labels", labels).Msgf("agent configured with labels") filter := rpc.Filter{ Labels: labels, } log.Debug().Msgf("agent registered with ID %d", agentConfig.AgentID) serviceWaitingGroup.Go(func() error { for { err := client.ReportHealth(grpcCtx) if err != nil { log.Err(err).Msg("failed to report health") // Check if the error is due to context cancellation if grpcCtx.Err() != nil || agentCtx.Err() != nil { log.Debug().Msg("terminating health reporting due to context cancellation") return nil } } select { case <-agentCtx.Done(): log.Debug().Msg("terminating health reporting") return nil case <-time.After(reportHealthInterval): } } }) // https://go.dev/blog/go1.22 fixed scope for goroutines in loops for i := range maxWorkflows { serviceWaitingGroup.Go(func() error { runner := agent.NewRunner(client, filter, hostname, counter, backendEngine) log.Debug().Msgf("created new runner %d", i) for { if agentCtx.Err() != nil { return nil } log.Debug().Msg("polling new workflow") if err := runner.Run(agentCtx); err != nil { if errors.Is(err, agent_rpc.ErrConnectionLost) { log.Error().Err(err).Msg("connection to server lost, shutting down agent") ctxCancel(err) return nil } if singleWorkflow { log.Error().Err(err).Msg("runner done with error") ctxCancel(nil) return nil } log.Error().Err(err).Msg("runner error, retrying...") // Check if context is canceled if agentCtx.Err() != nil { return nil } // Wait a bit before retrying to avoid hammering the server select { case <-agentCtx.Done(): return nil case <-time.After(time.Second * 5): // Continue to next iteration } } if singleWorkflow { log.Info().Msg("shutdown single workflow runner") ctxCancel(nil) return nil } } }) } log.Info(). Str("version", version.String()). Str("backend", backendEngine.Name()). Str("platform", engInfo.Platform). Int("parallel workflows", maxWorkflows). Bool("single workflow", singleWorkflow). Msg("starting Woodpecker agent") return serviceWaitingGroup.Wait() } func runWithRetry(backendEngines []types.Backend) func(ctx context.Context, c *cli.Command) error { return func(ctx context.Context, c *cli.Command) error { if err := logger.SetupGlobalLogger(ctx, c, true); err != nil { return err } initHealth() retryCount := c.Int("connect-retry-count") retryDelay := c.Duration("connect-retry-delay") var err error for range retryCount { if err = run(ctx, c, backendEngines); status.Code(err) == codes.Unavailable { log.Warn().Err(err).Msg(fmt.Sprintf("cannot connect to %s, retrying in %v", c.String("server"), retryDelay)) time.Sleep(retryDelay) } else { break } } return err } } func stringSliceAddToMap(sl []string, m map[string]string) error { if m == nil { m = make(map[string]string) } for _, v := range utils.StringSliceDeleteEmpty(sl) { before, after, _ := strings.Cut(v, "=") switch { case before != "" && after != "": m[before] = after case before != "": return fmt.Errorf("key '%s' does not have a value assigned", before) default: return fmt.Errorf("empty string in slice") } } return nil } ================================================ FILE: cmd/agent/core/agent_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "testing" "github.com/stretchr/testify/assert" ) func TestStringSliceAddToMap(t *testing.T) { tests := []struct { name string sl []string m map[string]string expected map[string]string err bool }{ { name: "add values to map", sl: []string{"foo=bar", "baz=qux=nux"}, m: make(map[string]string), expected: map[string]string{ "foo": "bar", "baz": "qux=nux", }, err: false, }, { name: "empty slice", sl: []string{}, m: make(map[string]string), expected: map[string]string{}, err: false, }, { name: "missing value", sl: []string{"foo", "baz=qux"}, m: make(map[string]string), expected: map[string]string{}, err: true, }, { name: "empty string in slice", sl: []string{"foo=bar", "", "baz=qux"}, m: make(map[string]string), expected: map[string]string{"foo": "bar", "baz": "qux"}, err: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := stringSliceAddToMap(tt.sl, tt.m) if tt.err { assert.Error(t, err) } else { assert.EqualValues(t, tt.expected, tt.m) } }) } } ================================================ FILE: cmd/agent/core/config.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2019 Laszlo Fogas // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "bytes" "encoding/json" "os" "strings" "github.com/rs/zerolog/log" ) type AgentConfig struct { AgentID int64 `json:"agent_id"` } const defaultAgentIDValue = int64(-1) func readAgentConfig(agentConfigPath string) AgentConfig { conf := AgentConfig{ AgentID: defaultAgentIDValue, } if agentConfigPath == "" { return conf } rawAgentConf, err := os.ReadFile(agentConfigPath) if err != nil { if os.IsNotExist(err) { log.Info().Msgf("no agent config found at '%s', start with defaults", agentConfigPath) } else { log.Error().Err(err).Msgf("could not open agent config at '%s'", agentConfigPath) } return conf } if strings.TrimSpace(string(rawAgentConf)) == "" { return conf } if err := json.Unmarshal(rawAgentConf, &conf); err != nil { log.Error().Err(err).Msg("could not parse agent config") } return conf } func writeAgentConfig(conf AgentConfig, agentConfigPath string) error { rawAgentConf, err := json.Marshal(conf) if err != nil { log.Error().Err(err).Msg("could not marshal agent config") return err } // get old config oldRawAgentConf, _ := os.ReadFile(agentConfigPath) // if config differ write to disk if !bytes.Equal(rawAgentConf, oldRawAgentConf) { if err := os.WriteFile(agentConfigPath, rawAgentConf, 0o644); err != nil { log.Error().Err(err).Msgf("could not persist agent config at '%s'", agentConfigPath) return err } } return nil } ================================================ FILE: cmd/agent/core/config_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadAgentIDFileNotExists(t *testing.T) { assert.EqualValues(t, -1, readAgentConfig("foobar.conf").AgentID) } func TestReadAgentIDFileExists(t *testing.T) { tmpF, errTmpF := os.CreateTemp(t.TempDir(), "tmp_") require.NoError(t, errTmpF) defer os.Remove(tmpF.Name()) // there is an existing config errWrite := os.WriteFile(tmpF.Name(), []byte(`{"agent_id":3}`), 0o644) require.NoError(t, errWrite) // read existing config actual := readAgentConfig(tmpF.Name()) assert.EqualValues(t, AgentConfig{3}, actual) // update existing config and check actual.AgentID = 33 _ = writeAgentConfig(actual, tmpF.Name()) actual = readAgentConfig(tmpF.Name()) assert.EqualValues(t, 33, actual.AgentID) tmpF2, errTmpF := os.CreateTemp(t.TempDir(), "tmp_") require.NoError(t, errTmpF) defer os.Remove(tmpF2.Name()) // write new config _ = writeAgentConfig(actual, tmpF2.Name()) actual = readAgentConfig(tmpF2.Name()) assert.EqualValues(t, 33, actual.AgentID) } ================================================ FILE: cmd/agent/core/flags.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2019 Laszlo Fogas // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "os" "time" "github.com/urfave/cli/v3" ) //nolint:mnd var flags = []cli.Flag{ &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SERVER"), Name: "server", Usage: "server address", Value: "localhost:9000", }, &cli.StringFlag{ Name: "grpc-token", Usage: "server-agent shared token", Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_AGENT_SECRET_FILE")), cli.EnvVar("WOODPECKER_AGENT_SECRET")), Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GRPC_SECURE"), Name: "grpc-secure", Usage: "should the connection to WOODPECKER_SERVER be made using a secure transport", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GRPC_VERIFY"), Name: "grpc-skip-insecure", Usage: "should the grpc server certificate be verified, only valid when WOODPECKER_GRPC_SECURE is true", Value: true, }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_RETRY_TIMEOUT"), Name: "retry-timeout", Usage: "how long the agent keeps retrying to reconnect to the server after the gRPC connection is lost before giving up, set to 0 to retry forever", Value: 2 * time.Minute, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_HOSTNAME"), Name: "hostname", Usage: "agent hostname", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_AGENT_CONFIG_FILE"), Name: "agent-config", Usage: "agent config file path, if set empty the agent will be stateless and unregister on termination", Value: "/etc/woodpecker/agent.conf", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_AGENT_LABELS", "WOODPECKER_FILTER_LABELS"), // remove WOODPECKER_FILTER_LABELS in v4.x Name: "labels", Aliases: []string{"filter"}, // remove in v4.x Usage: "List of labels to filter tasks on. An agent must be assigned every tag listed in a task to be selected.", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.IntFlag{ Sources: cli.EnvVars("WOODPECKER_MAX_WORKFLOWS", "WOODPECKER_MAX_PROCS"), // cspell:words PROCS Name: "max-workflows", Usage: "agent parallel workflows", Value: 1, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_AGENT_SINGLE_WORKFLOW"), Name: "single-workflow", Usage: "exit the agent after first workflow", Value: false, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_HEALTHCHECK"), Name: "healthcheck", Usage: "enable healthcheck endpoint", Value: true, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_HEALTHCHECK_ADDR"), Name: "healthcheck-addr", Usage: "healthcheck endpoint address", Value: ":3000", }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_KEEPALIVE_TIME"), Name: "keepalive-time", Usage: "after a duration of this time of no activity, the agent pings the server to check if the transport is still alive", }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_KEEPALIVE_TIMEOUT"), Name: "keepalive-timeout", Usage: "after pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity", Value: time.Second * 20, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND"), Name: "backend-engine", Usage: "backend to run pipelines on", Value: "auto-detect", }, &cli.IntFlag{ Sources: cli.EnvVars("WOODPECKER_CONNECT_RETRY_COUNT"), Name: "connect-retry-count", Usage: "number of times to retry connecting to the server", Value: 5, }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_CONNECT_RETRY_DELAY"), Name: "connect-retry-delay", Usage: "duration to wait before retrying to connect to the server", Value: time.Second * 2, }, } ================================================ FILE: cmd/agent/core/health.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "context" "encoding/json" "fmt" "net/http" "strings" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/agent" "go.woodpecker-ci.org/woodpecker/v3/version" ) // The file implements some basic healthcheck logic based on the // following specification: // https://github.com/mozilla-services/Dockerflow func initHealth() { http.HandleFunc("/varz", handleStats) http.HandleFunc("/healthz", handleHeartbeat) http.HandleFunc("/version", handleVersion) } func handleHeartbeat(w http.ResponseWriter, _ *http.Request) { if counter.Healthy() { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusInternalServerError) } } func handleVersion(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Add("Content-Type", "text/json") err := json.NewEncoder(w).Encode(versionResp{ Source: "https://github.com/woodpecker-ci/woodpecker", Version: version.String(), }) if err != nil { log.Error().Err(err).Msg("handleVersion") } } func handleStats(w http.ResponseWriter, _ *http.Request) { if counter.Healthy() { w.WriteHeader(http.StatusOK) } else { w.WriteHeader(http.StatusInternalServerError) } w.Header().Add("Content-Type", "text/json") if _, err := counter.WriteTo(w); err != nil { log.Error().Err(err).Msg("handleStats") } } type versionResp struct { Version string `json:"version"` Source string `json:"source"` } // Default statistics counter. var counter = &agent.State{ Metadata: map[string]agent.Info{}, } // handles pinging the endpoint and returns an error if the // agent is in an unhealthy state. func pinger(ctx context.Context, c *cli.Command) error { healthcheckAddress := c.String("healthcheck-addr") if strings.HasPrefix(healthcheckAddress, ":") { // this seems sufficient according to https://pkg.go.dev/net#Dial healthcheckAddress = "localhost" + healthcheckAddress } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+healthcheckAddress+"/healthz", nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("agent returned non-http.StatusOK status code") } return nil } ================================================ FILE: cmd/agent/core/health_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/agent" ) func TestHealthy(t *testing.T) { s := agent.State{} s.Metadata = map[string]agent.Info{} s.Add("1", time.Hour, "octocat/hello-world", "42") assert.Equal(t, "1", s.Metadata["1"].ID) assert.Equal(t, time.Hour, s.Metadata["1"].Timeout) assert.Equal(t, "octocat/hello-world", s.Metadata["1"].Repo) s.Metadata["1"] = agent.Info{ Timeout: time.Hour, Started: time.Now().UTC(), } assert.True(t, s.Healthy(), "want healthy status when timeout not exceeded, got false") s.Metadata["1"] = agent.Info{ Started: time.Now().UTC().Add(-(time.Minute * 30)), } assert.True(t, s.Healthy(), "want healthy status when timeout+buffer not exceeded, got false") s.Metadata["1"] = agent.Info{ Started: time.Now().UTC().Add(-(time.Hour + time.Minute)), } assert.False(t, s.Healthy(), "want unhealthy status when timeout+buffer not exceeded, got true") } ================================================ FILE: cmd/agent/core/run.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package core import ( "context" "os" "slices" // Load config from .env file. _ "github.com/joho/godotenv/autoload" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" "go.woodpecker-ci.org/woodpecker/v3/version" ) func GenApp(backends []backend_types.Backend) *cli.Command { app := &cli.Command{} app.Name = "woodpecker-agent" app.Version = version.String() app.Usage = "woodpecker agent" app.Action = runWithRetry(backends) app.Commands = []*cli.Command{ { Name: "ping", Usage: "ping the agent", Action: pinger, }, } agentFlags := slices.Concat(flags, logger.GlobalLoggerFlags) for _, b := range backends { agentFlags = slices.Concat(agentFlags, b.Flags()) } app.Flags = agentFlags return app } func RunAgent(ctx context.Context, backends []backend_types.Backend) { app := GenApp(backends) if err := app.Run(ctx, os.Args); err != nil { log.Fatal().Err(err).Msg("error running agent") //nolint:forbidigo } } ================================================ FILE: cmd/agent/dummy.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package main import "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" func init() { //nolint:gochecknoinits backends = append(backends, dummy.New()) } ================================================ FILE: cmd/agent/main.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) var backends = []backend_types.Backend{ kubernetes.New(), docker.New(), local.New(), } func main() { ctx := utils.WithContextSigtermCallback(context.Background(), func() { log.Info().Msg("termination signal is received, shutting down agent") }) core.RunAgent(ctx, backends) } ================================================ FILE: cmd/agent/man.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build man package main import ( "fmt" docs "github.com/urfave/cli-docs/v3" "go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/docker" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/kubernetes" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/local" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) var backends = []backend_types.Backend{ kubernetes.New(), docker.New(), local.New(), } func main() { app := core.GenApp(backends) md, err := docs.ToMan(app) if err != nil { panic(err) } fmt.Print(md) } ================================================ FILE: cmd/cli/app.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/cli/admin" "go.woodpecker-ci.org/woodpecker/v3/cli/common" "go.woodpecker-ci.org/woodpecker/v3/cli/context" "go.woodpecker-ci.org/woodpecker/v3/cli/exec" "go.woodpecker-ci.org/woodpecker/v3/cli/info" "go.woodpecker-ci.org/woodpecker/v3/cli/lint" "go.woodpecker-ci.org/woodpecker/v3/cli/org" "go.woodpecker-ci.org/woodpecker/v3/cli/pipeline" "go.woodpecker-ci.org/woodpecker/v3/cli/repo" "go.woodpecker-ci.org/woodpecker/v3/cli/setup" "go.woodpecker-ci.org/woodpecker/v3/cli/update" "go.woodpecker-ci.org/woodpecker/v3/version" ) //go:generate go run docs.go app.go func newApp() *cli.Command { app := &cli.Command{} app.Name = "woodpecker-cli" app.Description = "Woodpecker command line utility" app.Version = version.String() app.Usage = "command line utility" app.Flags = common.GlobalFlags app.Before = common.Before app.After = common.After app.Suggest = true app.ConfigureShellCompletionCommand = func(c *cli.Command) { c.Hidden = false c.Usage = "generate completion script for the specified shell" } app.Commands = []*cli.Command{ admin.Command, context.Command, exec.Command, info.Command, lint.Command, org.Command, pipeline.Command, repo.Command, setup.Command, update.Command, } return app } ================================================ FILE: cmd/cli/docs.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build generate package main import ( "os" docs "github.com/urfave/cli-docs/v3" ) func main() { app := newApp() md, err := docs.ToMarkdown(app) if err != nil { panic(err) } fi, err := os.Create("../../docs/docs/40-cli.md") if err != nil { panic(err) } defer fi.Close() if _, err := fi.WriteString("# CLI\n\n" + md); err != nil { panic(err) } } ================================================ FILE: cmd/cli/main.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "os" _ "github.com/joho/godotenv/autoload" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) func main() { ctx := utils.WithContextSigtermCallback(context.Background(), func() { log.Info().Msg("termination signal is received, terminate cli") }) app := newApp() if err := app.Run(ctx, os.Args); err != nil { log.Fatal().Err(err).Msg("error running cli") //nolint:forbidigo } } ================================================ FILE: cmd/cli/man.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build man package main import ( "fmt" docs "github.com/urfave/cli-docs/v3" ) func main() { app := newApp() md, err := docs.ToMan(app) if err != nil { panic(err) } fmt.Print(md) } ================================================ FILE: cmd/server/app.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/version" ) func genApp() *cli.Command { app := &cli.Command{} app.Name = "woodpecker-server" app.Version = version.String() app.Usage = "woodpecker server" app.Action = run app.Commands = []*cli.Command{ { Name: "ping", Usage: "ping the server", Action: pinger, }, } app.Flags = flags return app } ================================================ FILE: cmd/server/flags.go ================================================ // Copyright 2023 Woodpecker Authors // Copyright 2019 Laszlo Fogas // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "os" "time" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" ) var flags = append([]cli.Flag{ &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_LOG", "WOODPECKER_LOG_XORM"), Name: "db-log", Aliases: []string{"log-xorm"}, // TODO: remove in v4.0.0 Usage: "enable logging in database engine (currently xorm)", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_LOG_SQL", "WOODPECKER_LOG_XORM_SQL"), Name: "db-log-sql", Aliases: []string{"log-xorm-sql"}, // TODO: remove in v4.0.0 Usage: "enable logging of sql commands", }, &cli.IntFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_MAX_CONNECTIONS"), Name: "db-max-open-connections", Usage: "max connections xorm is allowed create", Value: 100, }, &cli.IntFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_IDLE_CONNECTIONS"), Name: "db-max-idle-connections", Usage: "amount of connections xorm will hold open", Value: 2, }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_CONNECTION_TIMEOUT"), Name: "db-max-connection-timeout", Usage: "time an active connection is allowed to stay open", Value: 3 * time.Second, }, &cli.UintFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_MAX_RETRIES"), Name: "db-max-retries", Usage: "max number of retries for the initial connection to the database", Value: 10, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_HOST"), Name: "server-host", Usage: "server fully qualified url. Format: ://[/]", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SERVER_ADDR"), Name: "server-addr", Usage: "server address", Value: ":8000", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SERVER_ADDR_TLS"), Name: "server-addr-tls", Usage: "port https with tls (:443)", Value: ":443", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SERVER_CERT"), Name: "server-cert", Usage: "server ssl cert path", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SERVER_KEY"), Name: "server-key", Usage: "server ssl key path", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_CUSTOM_CSS_FILE"), Name: "custom-css-file", Usage: "file path for the server to serve a custom .CSS file, used for customizing the UI", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_CUSTOM_JS_FILE"), Name: "custom-js-file", Usage: "file path for the server to serve a custom .JS file, used for customizing the UI", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_GRPC_ADDR"), Name: "grpc-addr", Usage: "grpc address", Value: ":9000", }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_GRPC_SECRET_FILE")), cli.EnvVar("WOODPECKER_GRPC_SECRET")), Name: "grpc-secret", Usage: "grpc jwt secret", Value: "secret", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_METRICS_SERVER_ADDR"), Name: "metrics-server-addr", Usage: "metrics server address", Value: "", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_ADMIN"), Name: "admin", Usage: "list of admin users", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_ORGS"), Name: "orgs", Usage: "list of approved organizations", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_REPO_OWNERS"), Name: "repo-owners", Usage: "Repositories by those owners will be allowed to be used in woodpecker", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_OPEN"), Name: "open", Usage: "enable open user registration", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_AUTHENTICATE_PUBLIC_REPOS"), Name: "authenticate-public-repos", Usage: "Always use authentication to clone repositories even if they are public. Needed if the SCM requires to always authenticate as used by many companies.", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS"), Name: "default-allow-pull-requests", Usage: "The default value for allowing pull requests on a repo.", Value: true, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_DEFAULT_APPROVAL_MODE"), Name: "default-approval-mode", Usage: "The default value for allowing pull requests on a repo.", Value: "forks", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS"), Name: "default-cancel-previous-pipeline-events", Usage: "List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created.", Value: []string{"push", "pull_request"}, Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_DEFAULT_CLONE_PLUGIN", "WOODPECKER_DEFAULT_CLONE_IMAGE"), Name: "default-clone-plugin", Aliases: []string{"default-clone-image"}, Usage: "The default docker image to be used when cloning the repo", Value: constant.DefaultClonePlugin, }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_DEFAULT_PIPELINE_TIMEOUT"), Name: "default-pipeline-timeout", Usage: "The default time in minutes for a repo in minutes before a pipeline gets killed", Value: 60, }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_MAX_PIPELINE_TIMEOUT"), Name: "max-pipeline-timeout", Usage: "The maximum time in minutes you can set in the repo settings before a pipeline gets killed", Value: 120, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_DEFAULT_WORKFLOW_LABELS"), Name: "default-workflow-labels", Usage: "The default label filter to set for workflows that has no label filter set. By default workflows will be allowed to run on any agent, if not specified in the workflow.", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_SESSION_EXPIRES"), Name: "session-expires", Usage: "session expiration time", Value: time.Hour * 72, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_PLUGINS_PRIVILEGED"), Name: "plugins-privileged", Usage: "Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_PLUGINS_TRUSTED_CLONE"), Name: "plugins-trusted-clone", Usage: "Plugins which are trusted to handle Git credentials in clone steps", Value: constant.TrustedClonePlugins, Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_VOLUME"), Name: "volume", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_DOCKER_CONFIG"), Name: "docker-config", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_ENVIRONMENT"), Name: "environment", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_NETWORK"), Name: "network", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_AGENT_SECRET_FILE")), cli.EnvVar("WOODPECKER_AGENT_SECRET")), Name: "agent-secret", Usage: "server-agent shared password", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DISABLE_USER_AGENT_REGISTRATION"), Name: "disable-user-agent-registration", Usage: "Disable user registered agents", }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_KEEPALIVE_MIN_TIME"), Name: "keepalive-min-time", Usage: "server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping.", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_CONFIG_EXTENSION_ENDPOINT", "WOODPECKER_CONFIG_SERVICE_ENDPOINT"), // TODO remove _SERVICE_ var in 4.0.0 Name: "config-extension-endpoint", Aliases: []string{"config-service-endpoint"}, // TODO: remove in v4.0.0 Usage: "url used for calling global configuration service endpoint", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_CONFIG_EXTENSION_EXCLUSIVE"), Name: "config-extension-exclusive", Usage: "whether global configuration service endpoint should be exclusive (skip forge)", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_CONFIG_EXTENSION_NETRC"), Name: "config-extension-netrc", Usage: "whether global configuration extension should receive netrc data", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_REGISTRY_EXTENSION_ENDPOINT"), Name: "registry-extension-endpoint", Usage: "url used for calling registry service endpoint", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_REGISTRY_EXTENSION_NETRC"), Name: "registry-extension-netrc", Usage: "whether global registry extension should receive netrc data", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_SECRET_EXTENSION_ENDPOINT"), Name: "secret-extension-endpoint", Usage: "url used for calling external secret service endpoint", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_SECRET_EXTENSION_NETRC"), Name: "secret-extension-netrc", Usage: "include netrc credentials in requests to secret service endpoint", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_EXTENSIONS_ALLOWED_HOSTS"), Name: "extensions-allowed-hosts", Usage: "Hosts that are allowed to be contacted by extensions", Value: hostmatcher.MatchBuiltinExternal, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_DATABASE_DRIVER"), Name: "db-driver", Aliases: []string{"driver"}, // TODO: remove in v4.0.0 Usage: "database driver", Value: "sqlite3", }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_DATABASE_DATASOURCE_FILE")), cli.EnvVar("WOODPECKER_DATABASE_DATASOURCE")), Name: "db-datasource", Aliases: []string{"datasource"}, // TODO: remove in v4.0.0 Usage: "database driver configuration string", Value: datasourceDefaultValue(), Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE")), cli.EnvVar("WOODPECKER_PROMETHEUS_AUTH_TOKEN")), Name: "prometheus-auth-token", Usage: "token to secure prometheus metrics endpoint", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_STATUS_CONTEXT", "WOODPECKER_GITHUB_CONTEXT", "WOODPECKER_GITEA_CONTEXT"), Name: "status-context", Usage: "status context prefix", Value: "ci/woodpecker", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_STATUS_CONTEXT_FORMAT"), Name: "status-context-format", Usage: "status context format", Value: "{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_MIGRATIONS_ALLOW_LONG"), Name: "migrations-allow-long", Value: false, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_ENABLE_SWAGGER"), Name: "enable-swagger", Value: true, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DISABLE_VERSION_CHECK"), Usage: "Disable version check in admin web ui.", Name: "skip-version-check", }, &cli.UintFlag{ Sources: cli.EnvVars("WOODPECKER_MAX_PIPELINE_LOG_LINE_COUNT"), Usage: "Maximum number of lines to show in a pipeline log, defaults to 5000.", Name: "max-pipeline-log-line-count", Value: 5000, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_LOG_STORE"), Name: "log-store", Usage: "log store to use ('database', 'addon' or 'file')", Value: "database", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_LOG_STORE_FILE_PATH"), Name: "log-store-file-path", Usage: "directory used for file based log storage or addon executable file path", }, // // backend options for pipeline compiler // &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_NO_PROXY", "NO_PROXY", "no_proxy"), Usage: "if set, pass the environment variable down as \"NO_PROXY\" to steps", Name: "backend-no-proxy", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_HTTP_PROXY", "HTTP_PROXY", "http_proxy"), Usage: "if set, pass the environment variable down as \"HTTP_PROXY\" to steps", Name: "backend-http-proxy", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_HTTPS_PROXY", "HTTPS_PROXY", "https_proxy"), Usage: "if set, pass the environment variable down as \"HTTPS_PROXY\" to steps", Name: "backend-https-proxy", }, // setting to have non breaking behavior till v4.0.0 &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE"), Name: "force-ignore-service-failure", Usage: "From v3.14.0 onwards, detached steps and services report their status back. To preserve the old behavior, service failures are ignored by default until v4.0.0.", Value: true, }, // // resource limit parameters // &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_FORGE_TIMEOUT"), Name: "forge-timeout", Usage: "how many seconds before timeout when fetching the Woodpecker configuration from a Forge", Value: time.Second * 5, }, &cli.UintFlag{ Sources: cli.EnvVars("WOODPECKER_FORGE_RETRY"), Name: "forge-retry", Usage: "How many retries of fetching the Woodpecker configuration from a forge are done before we fail", Value: 3, }, // // generic forge settings // &cli.StringFlag{ Name: "forge-url", Usage: "url of the forge", Sources: cli.EnvVars("WOODPECKER_FORGE_URL", "WOODPECKER_GITHUB_URL", "WOODPECKER_GITLAB_URL", "WOODPECKER_GITEA_URL", "WOODPECKER_FORGEJO_URL", "WOODPECKER_BITBUCKET_URL", "WOODPECKER_BITBUCKET_DC_URL"), }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(getFirstNonEmptyEnvVar( "WOODPECKER_FORGE_CLIENT_FILE", "WOODPECKER_GITHUB_CLIENT_FILE", "WOODPECKER_GITLAB_CLIENT_FILE", "WOODPECKER_GITEA_CLIENT_FILE", "WOODPECKER_FORGEJO_CLIENT_FILE", "WOODPECKER_BITBUCKET_CLIENT_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_ID_FILE")), cli.EnvVar("WOODPECKER_FORGE_CLIENT"), cli.EnvVar("WOODPECKER_GITHUB_CLIENT"), cli.EnvVar("WOODPECKER_GITLAB_CLIENT"), cli.EnvVar("WOODPECKER_GITEA_CLIENT"), cli.EnvVar("WOODPECKER_FORGEJO_CLIENT"), cli.EnvVar("WOODPECKER_BITBUCKET_CLIENT"), cli.EnvVar("WOODPECKER_BITBUCKET_DC_CLIENT_ID")), Name: "forge-oauth-client", Usage: "oauth2 client id", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(getFirstNonEmptyEnvVar( "WOODPECKER_FORGE_SECRET_FILE", "WOODPECKER_GITHUB_SECRET_FILE", "WOODPECKER_GITLAB_SECRET_FILE", "WOODPECKER_GITEA_SECRET_FILE", "WOODPECKER_FORGEJO_SECRET_FILE", "WOODPECKER_BITBUCKET_SECRET_FILE", "WOODPECKER_BITBUCKET_DC_CLIENT_SECRET_FILE", )), cli.EnvVar("WOODPECKER_FORGE_SECRET"), cli.EnvVar("WOODPECKER_GITHUB_SECRET"), cli.EnvVar("WOODPECKER_GITLAB_SECRET"), cli.EnvVar("WOODPECKER_GITEA_SECRET"), cli.EnvVar("WOODPECKER_FORGEJO_SECRET"), cli.EnvVar("WOODPECKER_BITBUCKET_SECRET"), cli.EnvVar("WOODPECKER_BITBUCKET_DC_CLIENT_SECRET")), Name: "forge-oauth-secret", Usage: "oauth2 client secret", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ Name: "forge-skip-verify", Usage: "skip ssl verification", Sources: cli.EnvVars( "WOODPECKER_FORGE_SKIP_VERIFY", "WOODPECKER_GITHUB_SKIP_VERIFY", "WOODPECKER_GITLAB_SKIP_VERIFY", "WOODPECKER_GITEA_SKIP_VERIFY", "WOODPECKER_FORGEJO_SKIP_VERIFY", "WOODPECKER_BITBUCKET_SKIP_VERIFY"), }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_EXPERT_FORGE_OAUTH_HOST"), Name: "forge-oauth-host", Usage: "fully qualified public forge url, used if forge url is not a public url. Format: ://[/]", }, // // Addon // &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_ADDON_FORGE"), Name: "addon-forge", Usage: "path to forge addon executable", }, // // GitHub // &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GITHUB"), Name: "github", Usage: "github driver is enabled", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GITHUB_MERGE_REF"), Name: "github-merge-ref", Usage: "github pull requests use merge ref", Value: true, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GITHUB_PUBLIC_ONLY"), Name: "github-public-only", Usage: "github tokens should only get access to public repos", Value: false, }, // // Gitea // &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GITEA"), Name: "gitea", Usage: "gitea driver is enabled", }, // // Forgejo // &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_FORGEJO"), Name: "forgejo", Usage: "forgejo driver is enabled", }, // // Bitbucket // &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BITBUCKET"), Name: "bitbucket", Usage: "bitbucket driver is enabled", }, // // Gitlab // &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_GITLAB"), Name: "gitlab", Usage: "gitlab driver is enabled", }, // // Bitbucket DataCenter/Server (previously Stash) // &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BITBUCKET_DC"), Name: "bitbucket-dc", Usage: "Bitbucket DataCenter/Server driver is enabled", }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE")), cli.EnvVar("WOODPECKER_BITBUCKET_DC_GIT_USERNAME")), Name: "bitbucket-dc-git-username", Usage: "Bitbucket DataCenter/Server service account username", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE")), cli.EnvVar("WOODPECKER_BITBUCKET_DC_GIT_PASSWORD")), Name: "bitbucket-dc-git-password", Usage: "Bitbucket DataCenter/Server service account password", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ // TODO: Remove this feature flag in next major version Sources: cli.EnvVars("WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN"), Name: "bitbucket-dc-oauth-enable-oauth2-scope-project-admin", Usage: "Bitbucket DataCenter/Server oauth2 scope should be configured to include PROJECT_ADMIN configuration.", }, // // development flags // &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_DEV_WWW_PROXY"), Name: "www-proxy", Usage: "serve the website by using a proxy (used for development)", Hidden: true, }, // // expert flags // &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_EXPERT_WEBHOOK_HOST"), Name: "server-webhook-host", Usage: "fully qualified woodpecker server url, called by the webhooks of the forge. Format: ://[/]", }, // // secrets encryption in DB // &cli.StringFlag{ Sources: cli.NewValueSourceChain( cli.File(os.Getenv("WOODPECKER_ENCRYPTION_KEY_FILE")), cli.EnvVar("WOODPECKER_ENCRYPTION_KEY")), Name: "encryption-raw-key", Usage: "Raw encryption key", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_ENCRYPTION_TINK_KEYSET_FILE"), Name: "encryption-tink-keyset", Usage: "Google tink AEAD-compatible keyset file to encrypt secrets in DB", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_ENCRYPTION_DISABLE"), Name: "encryption-disable-flag", Usage: "Flag to decrypt all encrypted data and disable encryption on server", }, }, logger.GlobalLoggerFlags...) // If woodpecker is running inside a container the default value for // the datasource is different from running outside a container. func datasourceDefaultValue() string { _, found := os.LookupEnv("WOODPECKER_IN_CONTAINER") if found { return "/var/lib/woodpecker/woodpecker.sqlite" } return "woodpecker.sqlite" } func getFirstNonEmptyEnvVar(envVars ...string) string { for _, envVar := range envVars { val := os.Getenv(envVar) if val != "" { return val } } return "" } ================================================ FILE: cmd/server/grpc_server.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "fmt" "net" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "google.golang.org/grpc" "google.golang.org/grpc/keepalive" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" "go.woodpecker-ci.org/woodpecker/v3/server" server_rpc "go.woodpecker-ci.org/woodpecker/v3/server/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func runGrpcServer(ctx context.Context, c *cli.Command, _store store.Store) error { lis, err := net.Listen("tcp", c.String("grpc-addr")) if err != nil { return fmt.Errorf("failed to listen on grpc-addr: %w", err) } jwtSecret := c.String("grpc-secret") jwtManager := server_rpc.NewJWTManager(jwtSecret) authorizer := server_rpc.NewAuthorizer(jwtManager) grpcServer := grpc.NewServer( grpc.StreamInterceptor(authorizer.StreamInterceptor), grpc.UnaryInterceptor(authorizer.UnaryInterceptor), grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: c.Duration("keepalive-min-time"), }), ) woodpeckerServer := server_rpc.NewWoodpeckerServer( server.Config.Services.Scheduler, server.Config.Services.Logs, _store, ) proto.RegisterWoodpeckerServer(grpcServer, woodpeckerServer) woodpeckerAuthServer := server_rpc.NewWoodpeckerAuthServer( jwtManager, server.Config.Server.AgentToken, _store, ) proto.RegisterWoodpeckerAuthServer(grpcServer, woodpeckerAuthServer) grpcCtx, cancel := context.WithCancelCause(ctx) defer cancel(nil) go func() { <-grpcCtx.Done() if grpcServer == nil { return } log.Info().Msg("terminating grpc service gracefully") grpcServer.GracefulStop() log.Info().Msg("grpc service stopped") }() if err := grpcServer.Serve(lis); err != nil { // signal that we don't have to stop the server gracefully anymore grpcServer = nil // wrap the error so we know where it did come from return fmt.Errorf("grpc server failed: %w", err) } return nil } ================================================ FILE: cmd/server/health.go ================================================ // Copyright 2023 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "fmt" "net/http" "strings" "time" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" ) const pingTimeout = 1 * time.Second // handles pinging the endpoint and returns an error if the // server is in an unhealthy state. func pinger(_ context.Context, c *cli.Command) error { scheme := "http" serverAddr := c.String("server-addr") if strings.HasPrefix(serverAddr, ":") { // this seems sufficient according to https://pkg.go.dev/net#Dial serverAddr = "localhost" + serverAddr } // if woodpecker do ssl on it's own if c.String("server-cert") != "" { scheme = "https" } // create the health url healthURL := fmt.Sprintf("%s://%s/healthz", scheme, serverAddr) log.Trace().Msgf("try to ping with url '%s'", healthURL) // ask server if all is healthy client := http.Client{Timeout: pingTimeout} resp, err := client.Get(healthURL) if err != nil { if strings.Contains(err.Error(), "deadline exceeded") { return fmt.Errorf("ping timeout reached after %s", pingTimeout) } return err } defer resp.Body.Close() if resp.StatusCode < 200 && resp.StatusCode >= 300 { return fmt.Errorf("server returned bad status code") } return nil } ================================================ FILE: cmd/server/main.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !generate && !man package main import ( "context" "os" _ "github.com/joho/godotenv/autoload" "github.com/rs/zerolog/log" _ "go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) func main() { ctx := utils.WithContextSigtermCallback(context.Background(), func() { log.Info().Msg("termination signal is received, shutting down server") }) app := genApp() setupOpenAPIStaticConfig() if err := app.Run(ctx, os.Args); err != nil { log.Error().Err(err).Msgf("error running server") } } ================================================ FILE: cmd/server/man.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build man package main import ( "fmt" _ "github.com/joho/godotenv/autoload" docs "github.com/urfave/cli-docs/v3" _ "go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi" ) func main() { app := genApp() md, err := docs.ToMan(app) if err != nil { panic(err) } fmt.Print(md) } ================================================ FILE: cmd/server/metrics_server.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "errors" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func startMetricsCollector(ctx context.Context, _store store.Store) { pendingSteps := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "pending_steps", Help: "Total number of pending pipeline steps.", }) waitingSteps := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "waiting_steps", Help: "Total number of pipeline waiting on deps.", }) runningSteps := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "running_steps", Help: "Total number of running pipeline steps.", }) workers := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "worker_count", Help: "Total number of workers.", }) pipelines := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "pipeline_total_count", Help: "Total number of pipelines.", }) users := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "user_count", Help: "Total number of users.", }) repos := promauto.NewGauge(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "repo_count", Help: "Total number of repos.", }) go func() { log.Info().Msg("queue metric collector started") for { stats := server.Config.Services.Scheduler.Info(ctx) pendingSteps.Set(float64(stats.Stats.Pending)) waitingSteps.Set(float64(stats.Stats.WaitingOnDeps)) runningSteps.Set(float64(stats.Stats.Running)) workers.Set(float64(stats.Stats.Workers)) select { case <-ctx.Done(): log.Info().Msg("queue metric collector stopped") return case <-time.After(queueInfoRefreshInterval): } } }() go func() { log.Info().Msg("store metric collector started") for { repoCount, repoErr := _store.GetRepoCount() userCount, userErr := _store.GetUserCount() pipelineCount, pipelineErr := _store.GetPipelineCount() pipelines.Set(float64(pipelineCount)) users.Set(float64(userCount)) repos.Set(float64(repoCount)) if err := errors.Join(repoErr, userErr, pipelineErr); err != nil { log.Error().Err(err).Msg("could not update store information for metrics") } select { case <-ctx.Done(): log.Info().Msg("store metric collector stopped") return case <-time.After(storeInfoRefreshInterval): } } }() } ================================================ FILE: cmd/server/openapi/docs.go ================================================ // Package openapi Code generated by swaggo/swag. DO NOT EDIT package openapi import "github.com/swaggo/swag" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, "swagger": "2.0", "info": { "description": "{{escape .Description}}", "title": "{{.Title}}", "contact": { "name": "Woodpecker CI", "url": "https://woodpecker-ci.org/" }, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { "/agents": { "get": { "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "List agents", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Agent" } } } } }, "post": { "description": "Creates a new agent with a random token", "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "Create a new agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "description": "the agent's data (only 'name' and 'no_schedule' are read)", "name": "agent", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Agent" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Agent" } } } } }, "/agents/{agent_id}": { "get": { "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "Get an agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the agent's id", "name": "agent_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Agent" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Agents" ], "summary": "Delete an agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the agent's id", "name": "agent_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "Update an agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the agent's id", "name": "agent_id", "in": "path", "required": true }, { "description": "the agent's data", "name": "agentData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Agent" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Agent" } } } } }, "/agents/{agent_id}/tasks": { "get": { "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "List agent tasks", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the agent's id", "name": "agent_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Task" } } } } } }, "/badges/{repo_id}/cc.xml": { "get": { "description": "CCMenu displays the pipeline status of projects on a CI server as an item in the Mac's menu bar.\nMore details on how to install, you can find at http://ccmenu.org/\nThe response format adheres to CCTray v1 Specification, https://cctray.org/v1/", "produces": [ "text/xml" ], "tags": [ "Badges" ], "summary": "Provide pipeline status information to the CCMenu tool", "parameters": [ { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/badges/{repo_id}/status.svg": { "get": { "produces": [ "image/svg+xml" ], "tags": [ "Badges" ], "summary": "Get status of pipeline as SVG badge", "parameters": [ { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug", "produces": [ "text/html" ], "tags": [ "Process profiling and debugging" ], "summary": "List available pprof profiles (HTML)", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/block": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof stack traces that led to blocking on synchronization primitives", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/cmdline": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get the command line invocation of the current program", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/goroutine": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof stack traces of all current goroutines", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "default": 1, "description": "Use debug=2 as a query parameter to export in the same format as an un-recovered panic", "name": "debug", "in": "query" } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/heap": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof heap dump, a sampling of memory allocations of live objects", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "default": "", "description": "You can specify gc=heap to run GC before taking the heap sample", "name": "gc", "in": "query" } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/profile": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\nAfter you get the profile file, use the go tool pprof command to investigate the profile.", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof CPU profile", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "You can specify the duration in the seconds GET parameter.", "name": "seconds", "in": "query", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/symbol": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\nLooks up the program counters listed in the request,\nresponding with a table mapping program counters to function names.\nThe requested program counters can be provided via GET + query parameters,\nor POST + body parameters. Program counters shall be space delimited.", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof program counters mapping to function names", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } }, "post": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\nLooks up the program counters listed in the request,\nresponding with a table mapping program counters to function names.\nThe requested program counters can be provided via GET + query parameters,\nor POST + body parameters. Program counters shall be space delimited.", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof program counters mapping to function names", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/threadcreate": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get pprof stack traces that led to the creation of new OS threads", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/debug/pprof/trace": { "get": { "description": "Only available, when server was started with WOODPECKER_LOG_LEVEL=debug\nAfter you get the profile file, use the go tool pprof command to investigate the profile.", "produces": [ "text/plain" ], "tags": [ "Process profiling and debugging" ], "summary": "Get a trace of execution of the current program", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "You can specify the duration in the seconds GET parameter.", "name": "seconds", "in": "query", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/forges": { "get": { "produces": [ "application/json" ], "tags": [ "Forges" ], "summary": "List forges", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header" }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Forge" } } } } }, "post": { "description": "Creates a new forge with a random token", "produces": [ "application/json" ], "tags": [ "Forges" ], "summary": "Create a new forge", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "description": "the forge's data (only 'name' and 'no_schedule' are read)", "name": "forge", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ForgeWithOAuthClientSecret" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Forge" } } } } }, "/forges/{forge_id}": { "get": { "produces": [ "application/json" ], "tags": [ "Forges" ], "summary": "Get a forge", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header" }, { "type": "integer", "description": "the forge's id", "name": "forge_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Forge" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Forges" ], "summary": "Delete a forge", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the forge's id", "name": "forge_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Forges" ], "summary": "Update a forge", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the forge's id", "name": "forge_id", "in": "path", "required": true }, { "description": "the forge's data", "name": "forgeData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ForgeWithOAuthClientSecret" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Forge" } } } } }, "/healthz": { "get": { "description": "If everything is fine, just a 204 will be returned, a 500 signals server state is unhealthy.", "produces": [ "text/plain" ], "tags": [ "System" ], "summary": "Health information", "responses": { "204": { "description": "No Content" }, "500": { "description": "Internal Server Error" } } } }, "/hook": { "post": { "produces": [ "text/plain" ], "tags": [ "System" ], "summary": "Incoming webhook from forge", "parameters": [ { "description": "the webhook payload; forge is automatically detected", "name": "hook", "in": "body", "required": true, "schema": { "type": "object" } } ], "responses": { "200": { "description": "OK" } } } }, "/log-level": { "get": { "description": "Endpoint returns the current logging level. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "System" ], "summary": "Current log level", "responses": { "200": { "description": "OK", "schema": { "type": "object", "properties": { "log-level": { "type": "string" } } } } } }, "post": { "description": "Endpoint sets the current logging level. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "System" ], "summary": "Set log level", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "description": "the new log level, one of \u003cdebug,trace,info,warn,error,fatal,panic,disabled\u003e", "name": "log-level", "in": "body", "required": true, "schema": { "type": "object", "properties": { "log-level": { "type": "string" } } } } ], "responses": { "200": { "description": "OK", "schema": { "type": "object", "properties": { "log-level": { "type": "string" } } } } } } }, "/orgs": { "get": { "description": "Returns all registered orgs in the system. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "Orgs" ], "summary": "List organizations", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Org" } } } } } }, "/orgs/lookup/{org_full_name}": { "get": { "produces": [ "application/json" ], "tags": [ "Orgs" ], "summary": "Lookup an organization by full name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the organizations full name / slug", "name": "org_full_name", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Org" } } } } }, "/orgs/{id}": { "delete": { "description": "Deletes the given org. Requires admin rights.", "produces": [ "text/plain" ], "tags": [ "Orgs" ], "summary": "Delete an organization", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/orgs/{org_id}": { "get": { "produces": [ "application/json" ], "tags": [ "Organization" ], "summary": "Get an organization", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the organization's id", "name": "org_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Org" } } } } } }, "/orgs/{org_id}/agents": { "get": { "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "List agents for an organization", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the organization's id", "name": "org_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Agent" } } } } }, "post": { "description": "Creates a new agent with a random token, scoped to the specified organization", "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "Create a new organization-scoped agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the organization's id", "name": "org_id", "in": "path", "required": true }, { "description": "the agent's data (only 'name' and 'no_schedule' are read)", "name": "agent", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Agent" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Agent" } } } } }, "/orgs/{org_id}/agents/{agent_id}": { "delete": { "produces": [ "text/plain" ], "tags": [ "Agents" ], "summary": "Delete an organization-scoped agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the organization's id", "name": "org_id", "in": "path", "required": true }, { "type": "integer", "description": "the agent's id", "name": "agent_id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Agents" ], "summary": "Update an organization-scoped agent", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the organization's id", "name": "org_id", "in": "path", "required": true }, { "type": "integer", "description": "the agent's id", "name": "agent_id", "in": "path", "required": true }, { "description": "the agent's updated data", "name": "agent", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Agent" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Agent" } } } } }, "/orgs/{org_id}/permissions": { "get": { "produces": [ "application/json" ], "tags": [ "Organization permissions" ], "summary": "Get the permissions of the currently authenticated user for the given organization", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the organization's id", "name": "org_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/OrgPerm" } } } } } }, "/orgs/{org_id}/registries": { "get": { "produces": [ "application/json" ], "tags": [ "Organization registries" ], "summary": "List organization registries", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Registry" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Organization registries" ], "summary": "Create an organization registry", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "description": "the new registry", "name": "registryData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Registry" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } } }, "/orgs/{org_id}/registries/{registry}": { "get": { "produces": [ "application/json" ], "tags": [ "Organization registries" ], "summary": "Get a organization registry by address", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "string", "description": "the registry's address", "name": "registry", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Organization registries" ], "summary": "Delete an organization registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "string", "description": "the registry's name", "name": "registry", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Organization registries" ], "summary": "Update an organization registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "string", "description": "the registry's name", "name": "registry", "in": "path", "required": true }, { "description": "the update registry data", "name": "registryData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Registry" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } } }, "/orgs/{org_id}/secrets": { "get": { "produces": [ "application/json" ], "tags": [ "Organization secrets" ], "summary": "List organization secrets", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Secret" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Organization secrets" ], "summary": "Create an organization secret", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "description": "the new secret", "name": "secretData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Secret" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } } }, "/orgs/{org_id}/secrets/{secret}": { "get": { "produces": [ "application/json" ], "tags": [ "Organization secrets" ], "summary": "Get a organization secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "string", "description": "the secret's name", "name": "secret", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Organization secrets" ], "summary": "Delete an organization secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "string", "description": "the secret's name", "name": "secret", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Organization secrets" ], "summary": "Update an organization secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the org's id", "name": "org_id", "in": "path", "required": true }, { "type": "string", "description": "the secret's name", "name": "secret", "in": "path", "required": true }, { "description": "the update secret data", "name": "secretData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/SecretPatch" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } } }, "/pipelines": { "get": { "produces": [ "application/json" ], "tags": [ "Pipeline queues" ], "summary": "List pipelines in queue", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Feed" } } } } } }, "/queue/info": { "get": { "description": "Returns pipeline queue information with agent details", "produces": [ "application/json" ], "tags": [ "Pipeline queues" ], "summary": "Get pipeline queue information", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/QueueInfo" } } } } }, "/queue/norunningpipelines": { "get": { "produces": [ "text/plain" ], "tags": [ "Pipeline queues" ], "summary": "Block til pipeline queue has a running item", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/queue/pause": { "post": { "produces": [ "text/plain" ], "tags": [ "Pipeline queues" ], "summary": "Pause the pipeline queue", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/queue/resume": { "post": { "produces": [ "text/plain" ], "tags": [ "Pipeline queues" ], "summary": "Resume the pipeline queue", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/registries": { "get": { "produces": [ "application/json" ], "tags": [ "Registries" ], "summary": "List global registries", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Registry" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Registries" ], "summary": "Create a global registry", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "description": "the registry object data", "name": "registry", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Registry" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } } }, "/registries/{registry}": { "get": { "produces": [ "application/json" ], "tags": [ "Registries" ], "summary": "Get a global registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the registry's name", "name": "registry", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Registries" ], "summary": "Delete a global registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the registry's name", "name": "registry", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Registries" ], "summary": "Update a global registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the registry's name", "name": "registry", "in": "path", "required": true }, { "description": "the registry's data", "name": "registryData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Registry" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } } }, "/repos": { "get": { "description": "Returns a list of all repositories. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "List all repositories on the server", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "boolean", "description": "only list active repos", "name": "active", "in": "query" }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Repo" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Activate a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the id of a repository at the forge", "name": "forge_remote_id", "in": "query", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Repo" } } } } }, "/repos/lookup/{repo_full_name}": { "get": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Lookup a repository by full name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the repository full name / slug", "name": "repo_full_name", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Repo" } } } } }, "/repos/repair": { "post": { "description": "Executes a repair process on all repositories. Requires admin rights.", "produces": [ "text/plain" ], "tags": [ "Repositories" ], "summary": "Repair all repositories on the server", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/repos/{repo_id}": { "get": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Get a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Repo" } } } }, "delete": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Delete a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Repo" } } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Update a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "description": "the repository's information", "name": "repo", "in": "body", "required": true, "schema": { "$ref": "#/definitions/RepoPatch" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Repo" } } } } }, "/repos/{repo_id}/branches": { "get": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Get branches of a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "type": "string" } } } } } }, "/repos/{repo_id}/chown": { "post": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Change a repository's owner to the currently authenticated user", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Repo" } } } } }, "/repos/{repo_id}/cron": { "get": { "produces": [ "application/json" ], "tags": [ "Repository cron jobs" ], "summary": "List cron jobs", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Cron" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Repository cron jobs" ], "summary": "Create a cron job", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "description": "the new cron job", "name": "cronJob", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Cron" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Cron" } } } } }, "/repos/{repo_id}/cron/{cron}": { "get": { "produces": [ "application/json" ], "tags": [ "Repository cron jobs" ], "summary": "Get a cron job", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the cron job id", "name": "cron", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Cron" } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Repository cron jobs" ], "summary": "Start a cron job now", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the cron job id", "name": "cron", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Pipeline" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Repository cron jobs" ], "summary": "Delete a cron job", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the cron job id", "name": "cron", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Repository cron jobs" ], "summary": "Update a cron job", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the cron job id", "name": "cron", "in": "path", "required": true }, { "description": "the cron job data", "name": "cronJob", "in": "body", "required": true, "schema": { "$ref": "#/definitions/CronPatch" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Cron" } } } } }, "/repos/{repo_id}/logs/{pipeline_number}": { "delete": { "produces": [ "text/plain" ], "tags": [ "Pipeline logs" ], "summary": "Deletes all logs of a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/repos/{repo_id}/logs/{pipeline_number}/{step_id}": { "get": { "produces": [ "application/json" ], "tags": [ "Pipeline logs" ], "summary": "Get logs for a pipeline step", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true }, { "type": "integer", "description": "the step id", "name": "step_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/LogEntry" } } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Pipeline logs" ], "summary": "Delete step logs of a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true }, { "type": "integer", "description": "the step id", "name": "step_id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/repos/{repo_id}/move": { "post": { "produces": [ "text/plain" ], "tags": [ "Repositories" ], "summary": "Move a repository to a new owner", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the username to move the repository to", "name": "to", "in": "query", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/repos/{repo_id}/permissions": { "get": { "description": "The repository permission, according to the used access token.", "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "Check current authenticated users access to the repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Perm" } } } } }, "/repos/{repo_id}/pipelines": { "get": { "description": "Get a list of pipelines for a repository.", "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "List repository pipelines", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" }, { "type": "string", "description": "only return pipelines before this RFC3339 date", "name": "before", "in": "query" }, { "type": "string", "description": "only return pipelines after this RFC3339 date", "name": "after", "in": "query" }, { "type": "string", "description": "filter pipelines by branch", "name": "branch", "in": "query" }, { "type": "string", "description": "filter pipelines by webhook events (comma separated)", "name": "event", "in": "query" }, { "type": "string", "description": "filter pipelines by strings contained in ref", "name": "ref", "in": "query" }, { "type": "string", "description": "filter pipelines by status", "name": "status", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Pipeline" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Trigger a manual pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "description": "the options for the pipeline to run", "name": "options", "in": "body", "required": true, "schema": { "$ref": "#/definitions/PipelineOptions" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Pipeline" } } } } }, "/repos/{repo_id}/pipelines/{pipeline_number}": { "get": { "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Get a repositories pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline, OR 'latest'", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Pipeline" } } } }, "post": { "description": "Restarts a pipeline optional with altered event, deploy or environment", "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Restart a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true }, { "type": "string", "description": "override the event type", "name": "event", "in": "query" }, { "type": "string", "description": "override the target deploy value", "name": "deploy_to", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Pipeline" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Pipelines" ], "summary": "Delete a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/repos/{repo_id}/pipelines/{pipeline_number}/approve": { "post": { "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Approve and start a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Pipeline" } } } } }, "/repos/{repo_id}/pipelines/{pipeline_number}/cancel": { "post": { "produces": [ "text/plain" ], "tags": [ "Pipelines" ], "summary": "Cancel a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/repos/{repo_id}/pipelines/{pipeline_number}/config": { "get": { "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Get configuration files for a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Config" } } } } } }, "/repos/{repo_id}/pipelines/{pipeline_number}/decline": { "post": { "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Decline a pipeline", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Pipeline" } } } } }, "/repos/{repo_id}/pipelines/{pipeline_number}/metadata": { "get": { "produces": [ "application/json" ], "tags": [ "Pipelines" ], "summary": "Get metadata for a pipeline or a specific workflow, including previous pipeline info", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline_number", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/metadata.Metadata" } } } } }, "/repos/{repo_id}/pull_requests": { "get": { "produces": [ "application/json" ], "tags": [ "Repositories" ], "summary": "List active pull requests of a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/PullRequest" } } } } } }, "/repos/{repo_id}/registries": { "get": { "produces": [ "application/json" ], "tags": [ "Repository registries" ], "summary": "List registries", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Registry" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Repository registries" ], "summary": "Create a registry", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "description": "the new registry data", "name": "registry", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Registry" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } } }, "/repos/{repo_id}/registries/{registry}": { "get": { "produces": [ "application/json" ], "tags": [ "Repository registries" ], "summary": "Get a registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the registry name", "name": "registry", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Repository registries" ], "summary": "Delete a registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the registry name", "name": "registry", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Repository registries" ], "summary": "Update a registry by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the registry name", "name": "registry", "in": "path", "required": true }, { "description": "the attributes for the registry", "name": "registryData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Registry" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Registry" } } } } }, "/repos/{repo_id}/repair": { "post": { "produces": [ "text/plain" ], "tags": [ "Repositories" ], "summary": "Repair a repository", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } } }, "/repos/{repo_id}/secrets": { "get": { "produces": [ "application/json" ], "tags": [ "Repository secrets" ], "summary": "List repository secrets", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Secret" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Repository secrets" ], "summary": "Create a repository secret", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "description": "the new secret", "name": "secret", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Secret" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } } }, "/repos/{repo_id}/secrets/{secretName}": { "get": { "produces": [ "application/json" ], "tags": [ "Repository secrets" ], "summary": "Get a repository secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the secret name", "name": "secretName", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Repository secrets" ], "summary": "Delete a repository secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the secret name", "name": "secretName", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Repository secrets" ], "summary": "Update a repository secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "string", "description": "the secret name", "name": "secretName", "in": "path", "required": true }, { "description": "the secret itself", "name": "secret", "in": "body", "required": true, "schema": { "$ref": "#/definitions/SecretPatch" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } } }, "/secrets": { "get": { "produces": [ "application/json" ], "tags": [ "Secrets" ], "summary": "List global secrets", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Secret" } } } } }, "post": { "produces": [ "application/json" ], "tags": [ "Secrets" ], "summary": "Create a global secret", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "description": "the secret object data", "name": "secret", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Secret" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } } }, "/secrets/{secret}": { "get": { "produces": [ "application/json" ], "tags": [ "Secrets" ], "summary": "Get a global secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the secret's name", "name": "secret", "in": "path", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } }, "delete": { "produces": [ "text/plain" ], "tags": [ "Secrets" ], "summary": "Delete a global secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the secret's name", "name": "secret", "in": "path", "required": true } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "produces": [ "application/json" ], "tags": [ "Secrets" ], "summary": "Update a global secret by name", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the secret's name", "name": "secret", "in": "path", "required": true }, { "description": "the secret's data", "name": "secretData", "in": "body", "required": true, "schema": { "$ref": "#/definitions/SecretPatch" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/Secret" } } } } }, "/signature/public-key": { "get": { "produces": [ "text/plain" ], "tags": [ "System" ], "summary": "Get server's signature public key", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/stream/events": { "get": { "description": "With quic and http2 support", "produces": [ "text/plain" ], "tags": [ "Events" ], "summary": "Stream events like pipeline updates", "responses": { "200": { "description": "OK" } } } }, "/stream/logs/{repo_id}/{pipeline}/{step_id}": { "get": { "produces": [ "text/plain" ], "tags": [ "Pipeline logs" ], "summary": "Stream logs of a pipeline step", "parameters": [ { "type": "integer", "description": "the repository id", "name": "repo_id", "in": "path", "required": true }, { "type": "integer", "description": "the number of the pipeline", "name": "pipeline", "in": "path", "required": true }, { "type": "integer", "description": "the step id", "name": "step_id", "in": "path", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/user": { "get": { "produces": [ "application/json" ], "tags": [ "User" ], "summary": "Get the currently authenticated user", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/User" } } } } }, "/user/feed": { "get": { "description": "The feed lists the most recent pipeline for the currently authenticated user.", "produces": [ "application/json" ], "tags": [ "User" ], "summary": "Get the currently authenticated users pipeline feed", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/Feed" } } } } } }, "/user/repos": { "get": { "description": "Retrieve the currently authenticated User's Repository list", "produces": [ "application/json" ], "tags": [ "User" ], "summary": "Get user's repositories", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "boolean", "description": "query all repos, including inactive ones", "name": "all", "in": "query" }, { "type": "string", "description": "filter repos by name", "name": "name", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/RepoLastPipeline" } } } } } }, "/user/token": { "post": { "produces": [ "text/plain" ], "tags": [ "User" ], "summary": "Return the token of the current user as string", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } }, "delete": { "description": "Reset's the current personal access token of the user and returns a new one.", "produces": [ "text/plain" ], "tags": [ "User" ], "summary": "Reset a token", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true } ], "responses": { "200": { "description": "OK" } } } }, "/users": { "get": { "description": "Returns all registered, active users in the system. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "Users" ], "summary": "List users", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "integer", "default": 1, "description": "for response pagination, page offset number", "name": "page", "in": "query" }, { "type": "integer", "default": 50, "description": "for response pagination, max items per page", "name": "perPage", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "type": "array", "items": { "$ref": "#/definitions/User" } } } } }, "post": { "description": "Creates a new user account with the specified external login. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "Users" ], "summary": "Create a user", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "description": "the user's data", "name": "user", "in": "body", "required": true, "schema": { "$ref": "#/definitions/User" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/User" } } } } }, "/users/{login}": { "get": { "description": "Returns a user with the specified login name. Requires admin rights.", "produces": [ "application/json" ], "tags": [ "Users" ], "summary": "Get a user", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the user's login name", "name": "login", "in": "path", "required": true }, { "type": "string", "description": "specify forge (else default will be used)", "name": "forge_id", "in": "query", "required": true }, { "type": "string", "description": "specify user id at forge (else fallback to login)", "name": "forge_remote_id", "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/User" } } } }, "delete": { "description": "Deletes the given user. Requires admin rights.", "produces": [ "text/plain" ], "tags": [ "Users" ], "summary": "Delete a user", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the user's login name", "name": "login", "in": "path", "required": true }, { "type": "string", "description": "specify forge (else default will be used)", "name": "forge_id", "in": "query", "required": true }, { "type": "string", "description": "specify user id at forge (else fallback to login)", "name": "forge_remote_id", "in": "query" } ], "responses": { "204": { "description": "No Content" } } }, "patch": { "description": "Changes the data of an existing user. Requires admin rights.", "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ "Users" ], "summary": "Update a user", "parameters": [ { "type": "string", "default": "Bearer \u003cpersonal access token\u003e", "description": "Insert your personal access token", "name": "Authorization", "in": "header", "required": true }, { "type": "string", "description": "the user's login name", "name": "login", "in": "path", "required": true }, { "description": "the user's data", "name": "user", "in": "body", "required": true, "schema": { "$ref": "#/definitions/User" } } ], "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/User" } } } } }, "/version": { "get": { "description": "Endpoint returns the server version and build information.", "produces": [ "application/json" ], "tags": [ "System" ], "summary": "Get version", "responses": { "200": { "description": "OK", "schema": { "type": "object", "properties": { "source": { "type": "string" }, "version": { "type": "string" } } } } } } } }, "definitions": { "Agent": { "type": "object", "properties": { "backend": { "type": "string" }, "capacity": { "type": "integer" }, "created": { "type": "integer" }, "custom_labels": { "type": "object", "additionalProperties": { "type": "string" } }, "id": { "type": "integer" }, "last_contact": { "type": "integer" }, "last_work": { "description": "last time the agent did something, this value is used to determine if the agent is still doing work used by the autoscaler", "type": "integer" }, "name": { "type": "string" }, "no_schedule": { "type": "boolean" }, "org_id": { "description": "OrgID is counted as unset if set to -1, this is done to ensure a new(Agent) still enforce the OrgID check by default", "type": "integer" }, "owner_id": { "type": "integer" }, "platform": { "type": "string" }, "token": { "type": "string" }, "updated": { "type": "integer" }, "version": { "type": "string" } } }, "CancelInfo": { "type": "object", "properties": { "canceled_by_step": { "type": "string" }, "canceled_by_user": { "type": "string" }, "superseded_by": { "type": "integer" } } }, "Config": { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "integer" } }, "hash": { "type": "string" }, "name": { "type": "string" } } }, "Cron": { "type": "object", "properties": { "branch": { "type": "string" }, "created": { "type": "integer" }, "creator_id": { "description": "TODO: drop with next major version", "type": "integer" }, "enabled": { "type": "boolean" }, "id": { "type": "integer" }, "name": { "type": "string" }, "next_exec": { "type": "integer" }, "repo_id": { "type": "integer" }, "schedule": { "description": "@weekly,\t3min, ...", "type": "string" }, "variables": { "type": "object", "additionalProperties": { "type": "string" } } } }, "CronPatch": { "type": "object", "properties": { "branch": { "type": "string" }, "enabled": { "type": "boolean" }, "name": { "type": "string" }, "schedule": { "type": "string" }, "variables": { "type": "object", "additionalProperties": { "type": "string" } } } }, "Feed": { "type": "object", "properties": { "author": { "type": "string" }, "author_avatar": { "type": "string" }, "author_email": { "type": "string" }, "branch": { "type": "string" }, "commit": { "type": "string" }, "created": { "type": "integer" }, "event": { "type": "string" }, "finished": { "type": "integer" }, "id": { "type": "integer" }, "message": { "type": "string" }, "number": { "type": "integer" }, "ref": { "type": "string" }, "refspec": { "type": "string" }, "repo_id": { "type": "integer" }, "started": { "type": "integer" }, "status": { "type": "string" }, "title": { "type": "string" } } }, "Forge": { "type": "object", "properties": { "additional_options": { "type": "object", "additionalProperties": {} }, "client": { "type": "string" }, "id": { "type": "integer" }, "oauth_host": { "description": "public url for oauth if different from url", "type": "string" }, "skip_verify": { "type": "boolean" }, "type": { "$ref": "#/definitions/model.ForgeType" }, "url": { "type": "string" } } }, "ForgeWithOAuthClientSecret": { "type": "object", "properties": { "additional_options": { "type": "object", "additionalProperties": {} }, "client": { "type": "string" }, "id": { "type": "integer" }, "oauth_client_secret": { "type": "string" }, "oauth_host": { "description": "public url for oauth if different from url", "type": "string" }, "skip_verify": { "type": "boolean" }, "type": { "$ref": "#/definitions/model.ForgeType" }, "url": { "type": "string" } } }, "LogEntry": { "type": "object", "properties": { "data": { "type": "array", "items": { "type": "integer" } }, "id": { "type": "integer" }, "line": { "type": "integer" }, "step_id": { "type": "integer" }, "time": { "type": "integer" }, "type": { "$ref": "#/definitions/LogEntryType" } } }, "LogEntryType": { "type": "integer", "enum": [ 0, 1, 2, 3, 4 ], "x-enum-varnames": [ "LogEntryStdout", "LogEntryStderr", "LogEntryExitCode", "LogEntryMetadata", "LogEntryProgress" ] }, "Org": { "type": "object", "properties": { "forge_id": { "type": "integer" }, "id": { "type": "integer" }, "is_user": { "type": "boolean" }, "name": { "type": "string" } } }, "OrgPerm": { "type": "object", "properties": { "admin": { "type": "boolean" }, "member": { "type": "boolean" } } }, "Perm": { "type": "object", "properties": { "admin": { "type": "boolean" }, "created": { "type": "integer" }, "pull": { "type": "boolean" }, "push": { "type": "boolean" }, "synced": { "type": "integer" }, "updated": { "type": "integer" } } }, "Pipeline": { "type": "object", "properties": { "author": { "type": "string" }, "author_avatar": { "type": "string" }, "author_email": { "type": "string" }, "branch": { "type": "string" }, "cancel_info": { "$ref": "#/definitions/CancelInfo" }, "changed_files": { "type": "array", "items": { "type": "string" } }, "commit": { "type": "string" }, "created": { "type": "integer" }, "cron": { "description": "name of the cron job", "type": "string" }, "deploy_task": { "type": "string" }, "deploy_to": { "type": "string" }, "errors": { "type": "array", "items": { "$ref": "#/definitions/errors.PipelineError" } }, "event": { "$ref": "#/definitions/WebhookEvent" }, "event_reason": { "type": "array", "items": { "type": "string" } }, "finished": { "type": "integer" }, "forge_url": { "type": "string" }, "from_fork": { "type": "boolean" }, "id": { "type": "integer" }, "is_prerelease": { "type": "boolean" }, "message": { "type": "string" }, "number": { "type": "integer" }, "parent": { "type": "integer" }, "pr_labels": { "type": "array", "items": { "type": "string" } }, "pr_milestone": { "type": "string" }, "ref": { "type": "string" }, "refspec": { "type": "string" }, "reviewed": { "type": "integer" }, "reviewed_by": { "type": "string" }, "sender": { "description": "uses reported user for webhooks and name of cron for cron pipelines", "type": "string" }, "started": { "type": "integer" }, "status": { "$ref": "#/definitions/StatusValue" }, "timestamp": { "type": "integer" }, "title": { "type": "string" }, "updated": { "type": "integer" }, "variables": { "type": "object", "additionalProperties": { "type": "string" } }, "version": { "type": "string" }, "workflows": { "type": "array", "items": { "$ref": "#/definitions/model.Workflow" } } } }, "PipelineOptions": { "type": "object", "properties": { "branch": { "type": "string" }, "variables": { "type": "object", "additionalProperties": { "type": "string" } } } }, "PullRequest": { "type": "object", "properties": { "index": { "type": "string" }, "title": { "type": "string" } } }, "QueueInfo": { "type": "object", "properties": { "paused": { "type": "boolean" }, "pending": { "type": "array", "items": { "$ref": "#/definitions/model.QueueTask" } }, "running": { "type": "array", "items": { "$ref": "#/definitions/model.QueueTask" } }, "stats": { "type": "object", "properties": { "pending_count": { "type": "integer" }, "running_count": { "type": "integer" }, "waiting_on_deps_count": { "type": "integer" }, "worker_count": { "type": "integer" } } }, "waiting_on_deps": { "type": "array", "items": { "$ref": "#/definitions/model.QueueTask" } } } }, "Registry": { "type": "object", "properties": { "address": { "type": "string" }, "id": { "type": "integer" }, "org_id": { "type": "integer" }, "password": { "type": "string" }, "readonly": { "type": "boolean" }, "repo_id": { "type": "integer" }, "username": { "type": "string" } } }, "Repo": { "type": "object", "properties": { "active": { "type": "boolean" }, "allow_deploy": { "type": "boolean" }, "allow_pr": { "type": "boolean" }, "approval_allowed_users": { "type": "array", "items": { "type": "string" } }, "avatar_url": { "type": "string" }, "cancel_previous_pipeline_events": { "type": "array", "items": { "$ref": "#/definitions/WebhookEvent" } }, "clone_url": { "type": "string" }, "clone_url_ssh": { "type": "string" }, "config_extension_endpoint": { "type": "string" }, "config_extension_exclusive": { "type": "boolean" }, "config_extension_netrc": { "type": "boolean" }, "config_file": { "type": "string" }, "default_branch": { "type": "string" }, "forge_id": { "type": "integer" }, "forge_remote_id": { "description": "ForgeRemoteID is the unique identifier for the repository on the forge.", "type": "string" }, "forge_url": { "type": "string" }, "full_name": { "type": "string" }, "has_forge_name_conflict": { "description": "HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id", "type": "boolean" }, "has_no_forge_repo": { "description": "HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore", "type": "boolean" }, "id": { "type": "integer" }, "name": { "type": "string" }, "netrc_trusted": { "type": "array", "items": { "type": "string" } }, "org_id": { "type": "integer" }, "owner": { "type": "string" }, "pr_enabled": { "type": "boolean" }, "private": { "type": "boolean" }, "registry_extension_endpoint": { "type": "string" }, "registry_extension_netrc": { "type": "boolean" }, "require_approval": { "$ref": "#/definitions/model.ApprovalMode" }, "secret_extension_endpoint": { "type": "string" }, "secret_extension_netrc": { "type": "boolean" }, "timeout": { "type": "integer" }, "trusted": { "$ref": "#/definitions/model.TrustedConfiguration" }, "visibility": { "$ref": "#/definitions/RepoVisibility" } } }, "RepoLastPipeline": { "type": "object", "properties": { "active": { "type": "boolean" }, "allow_deploy": { "type": "boolean" }, "allow_pr": { "type": "boolean" }, "approval_allowed_users": { "type": "array", "items": { "type": "string" } }, "avatar_url": { "type": "string" }, "cancel_previous_pipeline_events": { "type": "array", "items": { "$ref": "#/definitions/WebhookEvent" } }, "clone_url": { "type": "string" }, "clone_url_ssh": { "type": "string" }, "config_extension_endpoint": { "type": "string" }, "config_extension_exclusive": { "type": "boolean" }, "config_extension_netrc": { "type": "boolean" }, "config_file": { "type": "string" }, "default_branch": { "type": "string" }, "forge_id": { "type": "integer" }, "forge_remote_id": { "description": "ForgeRemoteID is the unique identifier for the repository on the forge.", "type": "string" }, "forge_url": { "type": "string" }, "full_name": { "type": "string" }, "has_forge_name_conflict": { "description": "HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id", "type": "boolean" }, "has_no_forge_repo": { "description": "HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore", "type": "boolean" }, "id": { "type": "integer" }, "last_pipeline": { "$ref": "#/definitions/Pipeline" }, "name": { "type": "string" }, "netrc_trusted": { "type": "array", "items": { "type": "string" } }, "org_id": { "type": "integer" }, "owner": { "type": "string" }, "pr_enabled": { "type": "boolean" }, "private": { "type": "boolean" }, "registry_extension_endpoint": { "type": "string" }, "registry_extension_netrc": { "type": "boolean" }, "require_approval": { "$ref": "#/definitions/model.ApprovalMode" }, "secret_extension_endpoint": { "type": "string" }, "secret_extension_netrc": { "type": "boolean" }, "timeout": { "type": "integer" }, "trusted": { "$ref": "#/definitions/model.TrustedConfiguration" }, "visibility": { "$ref": "#/definitions/RepoVisibility" } } }, "RepoPatch": { "type": "object", "properties": { "allow_deploy": { "type": "boolean" }, "allow_pr": { "type": "boolean" }, "approval_allowed_users": { "type": "array", "items": { "type": "string" } }, "cancel_previous_pipeline_events": { "type": "array", "items": { "$ref": "#/definitions/WebhookEvent" } }, "config_extension_endpoint": { "type": "string" }, "config_extension_exclusive": { "type": "boolean" }, "config_extension_netrc": { "type": "boolean" }, "config_file": { "type": "string" }, "netrc_trusted": { "type": "array", "items": { "type": "string" } }, "registry_extension_endpoint": { "type": "string" }, "registry_extension_netrc": { "type": "boolean" }, "require_approval": { "type": "string" }, "secret_extension_endpoint": { "type": "string" }, "secret_extension_netrc": { "type": "boolean" }, "timeout": { "type": "integer" }, "trusted": { "$ref": "#/definitions/model.TrustedConfigurationPatch" }, "visibility": { "type": "string" } } }, "RepoVisibility": { "type": "string", "enum": [ "public", "private", "internal" ], "x-enum-varnames": [ "VisibilityPublic", "VisibilityPrivate", "VisibilityInternal" ] }, "Secret": { "type": "object", "properties": { "events": { "type": "array", "items": { "$ref": "#/definitions/WebhookEvent" } }, "id": { "type": "integer" }, "images": { "type": "array", "items": { "type": "string" } }, "name": { "type": "string" }, "note": { "type": "string" }, "org_id": { "type": "integer" }, "repo_id": { "type": "integer" }, "value": { "type": "string" } } }, "SecretPatch": { "type": "object", "properties": { "events": { "type": "array", "items": { "$ref": "#/definitions/WebhookEvent" } }, "images": { "type": "array", "items": { "type": "string" } }, "name": { "type": "string" }, "note": { "type": "string" }, "value": { "type": "string" } } }, "StatusValue": { "type": "string", "enum": [ "skipped", "pending", "running", "success", "failure", "killed", "canceled", "error", "blocked", "declined", "created" ], "x-enum-comments": { "StatusBlocked": "waiting for approval", "StatusCanceled": "canceled but hasn't been started", "StatusCreated": "created / internal use only", "StatusDeclined": "blocked and declined", "StatusError": "error with the config / while parsing / some other system problem", "StatusFailure": "failed to finish (exit code != 0)", "StatusKilled": "killed by user", "StatusPending": "pending to be executed", "StatusRunning": "currently running", "StatusSkipped": "skipped as per condition of current workflow failed/success state", "StatusSuccess": "successfully finished" }, "x-enum-descriptions": [ "skipped as per condition of current workflow failed/success state", "pending to be executed", "currently running", "successfully finished", "failed to finish (exit code != 0)", "killed by user", "canceled but hasn't been started", "error with the config / while parsing / some other system problem", "waiting for approval", "blocked and declined", "created / internal use only" ], "x-enum-varnames": [ "StatusSkipped", "StatusPending", "StatusRunning", "StatusSuccess", "StatusFailure", "StatusKilled", "StatusCanceled", "StatusError", "StatusBlocked", "StatusDeclined", "StatusCreated" ] }, "Step": { "type": "object", "properties": { "error": { "type": "string" }, "exit_code": { "type": "integer" }, "finished": { "type": "integer" }, "id": { "type": "integer" }, "name": { "type": "string" }, "pid": { "type": "integer" }, "pipeline_id": { "type": "integer" }, "ppid": { "type": "integer" }, "started": { "type": "integer" }, "state": { "$ref": "#/definitions/StatusValue" }, "type": { "$ref": "#/definitions/StepType" }, "uuid": { "type": "string" } } }, "StepType": { "type": "string", "enum": [ "clone", "service", "plugin", "commands", "cache" ], "x-enum-varnames": [ "StepTypeClone", "StepTypeService", "StepTypePlugin", "StepTypeCommands", "StepTypeCache" ] }, "Task": { "type": "object", "properties": { "agent_id": { "type": "integer" }, "dep_status": { "type": "object", "additionalProperties": { "$ref": "#/definitions/StatusValue" } }, "dependencies": { "type": "array", "items": { "type": "string" } }, "id": { "type": "string" }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, "name": { "type": "string" }, "pid": { "type": "integer" }, "pipeline_id": { "type": "integer" }, "repo_id": { "type": "integer" }, "run_on": { "type": "array", "items": { "type": "string" } } } }, "User": { "type": "object", "properties": { "admin": { "description": "Admin indicates the user is a system administrator.\n\nNOTE: If the username is part of the WOODPECKER_ADMIN\nenvironment variable, this value will be set to true on login.", "type": "boolean" }, "avatar_url": { "description": "the avatar url for this user.", "type": "string" }, "email": { "description": "Email is the email address for this user.\n\nrequired: true", "type": "string" }, "forge_id": { "type": "integer" }, "forge_remote_id": { "type": "string" }, "id": { "description": "the id for this user.\n\nrequired: true", "type": "integer" }, "login": { "description": "Login is the username for this user.\n\nrequired: true", "type": "string" }, "org_id": { "description": "OrgID is the of the user as model.Org.", "type": "integer" } } }, "WebhookEvent": { "type": "string", "enum": [ "push", "pull_request", "pull_request_closed", "pull_request_metadata", "tag", "release", "deployment", "cron", "manual" ], "x-enum-varnames": [ "EventPush", "EventPull", "EventPullClosed", "EventPullMetadata", "EventTag", "EventRelease", "EventDeploy", "EventCron", "EventManual" ] }, "errors.PipelineError": { "type": "object", "properties": { "data": {}, "is_warning": { "type": "boolean" }, "message": { "type": "string" }, "type": { "$ref": "#/definitions/errors.PipelineErrorType" } } }, "errors.PipelineErrorType": { "type": "string", "enum": [ "linter", "deprecation", "compiler", "generic", "bad_habit" ], "x-enum-comments": { "PipelineErrorTypeBadHabit": "some bad-habit error", "PipelineErrorTypeCompiler": "some error with the config semantics", "PipelineErrorTypeDeprecation": "using some deprecated feature", "PipelineErrorTypeGeneric": "some generic error", "PipelineErrorTypeLinter": "some error with the config syntax" }, "x-enum-descriptions": [ "some error with the config syntax", "using some deprecated feature", "some error with the config semantics", "some generic error", "some bad-habit error" ], "x-enum-varnames": [ "PipelineErrorTypeLinter", "PipelineErrorTypeDeprecation", "PipelineErrorTypeCompiler", "PipelineErrorTypeGeneric", "PipelineErrorTypeBadHabit" ] }, "metadata.Author": { "type": "object", "properties": { "email": { "type": "string" }, "name": { "type": "string" } } }, "metadata.Commit": { "type": "object", "properties": { "author": { "$ref": "#/definitions/metadata.Author" }, "branch": { "type": "string" }, "changed_files": { "type": "array", "items": { "type": "string" } }, "is_prerelease": { "type": "boolean" }, "labels": { "type": "array", "items": { "type": "string" } }, "message": { "type": "string" }, "milestone": { "type": "string" }, "ref": { "type": "string" }, "refspec": { "type": "string" }, "sha": { "type": "string" } } }, "metadata.Event": { "type": "string", "enum": [ "push", "pull_request", "pull_request_closed", "pull_request_metadata", "tag", "release", "deployment", "cron", "manual" ], "x-enum-varnames": [ "EventPush", "EventPull", "EventPullClosed", "EventPullMetadata", "EventTag", "EventRelease", "EventDeploy", "EventCron", "EventManual" ] }, "metadata.Forge": { "type": "object", "properties": { "type": { "type": "string" }, "url": { "type": "string" } } }, "metadata.Metadata": { "type": "object", "properties": { "curr": { "$ref": "#/definitions/metadata.Pipeline" }, "forge": { "$ref": "#/definitions/metadata.Forge" }, "id": { "type": "string" }, "prev": { "$ref": "#/definitions/metadata.Pipeline" }, "repo": { "$ref": "#/definitions/metadata.Repo" }, "step": { "$ref": "#/definitions/metadata.Step" }, "sys": { "$ref": "#/definitions/metadata.System" }, "workflow": { "$ref": "#/definitions/metadata.Workflow" } } }, "metadata.Pipeline": { "type": "object", "properties": { "author": { "type": "string" }, "avatar": { "type": "string" }, "commit": { "$ref": "#/definitions/metadata.Commit" }, "created": { "type": "integer" }, "cron": { "type": "string" }, "event": { "$ref": "#/definitions/metadata.Event" }, "event_reason": { "type": "array", "items": { "type": "string" } }, "finished": { "type": "integer" }, "forge_url": { "type": "string" }, "number": { "type": "integer" }, "parent": { "type": "integer" }, "started": { "type": "integer" }, "status": { "type": "string" }, "target": { "type": "string" }, "task": { "type": "string" } } }, "metadata.Repo": { "type": "object", "properties": { "clone_url": { "type": "string" }, "clone_url_ssh": { "type": "string" }, "default_branch": { "type": "string" }, "forge_url": { "type": "string" }, "id": { "type": "integer" }, "name": { "type": "string" }, "owner": { "type": "string" }, "private": { "type": "boolean" }, "remote_id": { "type": "string" }, "trusted": { "$ref": "#/definitions/metadata.TrustedConfiguration" } } }, "metadata.Step": { "type": "object", "properties": { "name": { "type": "string" }, "number": { "type": "integer" } } }, "metadata.System": { "type": "object", "properties": { "arch": { "type": "string" }, "host": { "type": "string" }, "name": { "type": "string" }, "url": { "type": "string" }, "version": { "type": "string" } } }, "metadata.TrustedConfiguration": { "type": "object", "properties": { "network": { "type": "boolean" }, "security": { "type": "boolean" }, "volumes": { "type": "boolean" } } }, "metadata.Workflow": { "type": "object", "properties": { "matrix": { "type": "object", "additionalProperties": { "type": "string" } }, "name": { "type": "string" }, "number": { "type": "integer" } } }, "model.ApprovalMode": { "type": "string", "enum": [ "none", "forks", "pull_requests", "all_events" ], "x-enum-comments": { "RequireApprovalAllEvents": "require approval for all external events", "RequireApprovalForks": "require approval for PRs from forks (default)", "RequireApprovalNone": "require approval for no events", "RequireApprovalPullRequests": "require approval for all PRs" }, "x-enum-descriptions": [ "require approval for no events", "require approval for PRs from forks (default)", "require approval for all PRs", "require approval for all external events" ], "x-enum-varnames": [ "RequireApprovalNone", "RequireApprovalForks", "RequireApprovalPullRequests", "RequireApprovalAllEvents" ] }, "model.ForgeType": { "type": "string", "enum": [ "github", "gitlab", "gitea", "forgejo", "bitbucket", "bitbucket-dc", "addon" ], "x-enum-varnames": [ "ForgeTypeGithub", "ForgeTypeGitlab", "ForgeTypeGitea", "ForgeTypeForgejo", "ForgeTypeBitbucket", "ForgeTypeBitbucketDatacenter", "ForgeTypeAddon" ] }, "model.QueueTask": { "type": "object", "properties": { "agent_id": { "type": "integer" }, "agent_name": { "type": "string" }, "dep_status": { "type": "object", "additionalProperties": { "$ref": "#/definitions/StatusValue" } }, "dependencies": { "type": "array", "items": { "type": "string" } }, "id": { "type": "string" }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, "name": { "type": "string" }, "pid": { "type": "integer" }, "pipeline_id": { "type": "integer" }, "pipeline_number": { "type": "integer" }, "repo_id": { "type": "integer" }, "run_on": { "type": "array", "items": { "type": "string" } } } }, "model.TrustedConfiguration": { "type": "object", "properties": { "network": { "type": "boolean" }, "security": { "type": "boolean" }, "volumes": { "type": "boolean" } } }, "model.TrustedConfigurationPatch": { "type": "object", "properties": { "network": { "type": "boolean" }, "security": { "type": "boolean" }, "volumes": { "type": "boolean" } } }, "model.Workflow": { "type": "object", "properties": { "agent_id": { "type": "integer" }, "children": { "type": "array", "items": { "$ref": "#/definitions/Step" } }, "environ": { "type": "object", "additionalProperties": { "type": "string" } }, "error": { "type": "string" }, "finished": { "type": "integer" }, "id": { "type": "integer" }, "name": { "type": "string" }, "pid": { "type": "integer" }, "pipeline_id": { "type": "integer" }, "platform": { "type": "string" }, "started": { "type": "integer" }, "state": { "$ref": "#/definitions/StatusValue" } } } } }` // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ Version: "", Host: "", BasePath: "/api", Schemes: []string{}, Title: "Woodpecker CI API", Description: "Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.\nTo get a personal access token (PAT) for authentication, please log in your Woodpecker server,\nand go to you personal profile page, by clicking the user icon at the top right.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", RightDelim: "}}", } func init() { swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) } ================================================ FILE: cmd/server/openapi.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi" "go.woodpecker-ci.org/woodpecker/v3/version" ) // Generate docs/openapi.json via: //go:generate go run github.com/swaggo/swag/cmd/swag init -g cmd/server/openapi.go --outputTypes go -output openapi -d ../../ //go:generate go run openapi_json_gen.go openapi.go //go:generate go run github.com/getkin/kin-openapi/cmd/validate ../../docs/openapi.json // setupOpenAPIStaticConfig initializes static content (version) for the OpenAPI config. // // @title Woodpecker CI API // @description Woodpecker is a simple, yet powerful CI/CD engine with great extensibility. // @description To get a personal access token (PAT) for authentication, please log in your Woodpecker server, // @description and go to you personal profile page, by clicking the user icon at the top right. // @BasePath /api // @contact.name Woodpecker CI // @contact.url https://woodpecker-ci.org/ func setupOpenAPIStaticConfig() { openapi.SwaggerInfo.Version = version.String() } ================================================ FILE: cmd/server/openapi_json_gen.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ********************************************************* // This is a generator tool, to update the openapi.json file // ********************************************************* //go:build generate package main import ( "context" "encoding/json" "fmt" "os" "path" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi2conv" "go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi" ) func main() { // set openapi infos setupOpenAPIStaticConfig() basePath := path.Join("..", "..") filePath := path.Join(basePath, "docs", "openapi.json") // generate openapi file f, err := os.Create(filePath) if err != nil { panic(err) } defer f.Close() doc := openapi.SwaggerInfo.ReadDoc() doc, err = removeHost(doc) if err != nil { panic(err) } _, err = f.WriteString(doc) if err != nil { panic(err) } fmt.Println("generated openapi.json") // convert to OpenApi3 if err := toOpenApi3(filePath, filePath); err != nil { fmt.Printf("converting '%s' from openapi v2 to v3 failed\n", filePath) panic(err) } } func removeHost(jsonIn string) (string, error) { m := make(map[string]interface{}) if err := json.Unmarshal([]byte(jsonIn), &m); err != nil { return "", err } delete(m, "host") raw, err := json.Marshal(m) if err != nil { return "", err } return string(raw), nil } func toOpenApi3(input, output string) error { data2, err := os.ReadFile(input) if err != nil { return fmt.Errorf("read input: %w", err) } var doc2 openapi2.T err = json.Unmarshal(data2, &doc2) if err != nil { return fmt.Errorf("unmarshal input: %w", err) } doc3, err := openapi2conv.ToV3(&doc2) if err != nil { return fmt.Errorf("convert openapi v2 to v3: %w", err) } err = doc3.Validate(context.Background()) if err != nil { return err } data, err := json.Marshal(doc3) if err != nil { return fmt.Errorf("Marshal converted: %w", err) } if err = os.WriteFile(output, data, 0o644); err != nil { return fmt.Errorf("write output: %w", err) } return nil } ================================================ FILE: cmd/server/openapi_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi" ) func TestSetupOpenApiStaticConfig(t *testing.T) { setupOpenAPIStaticConfig() assert.Equal(t, "/api", openapi.SwaggerInfo.BasePath) } ================================================ FILE: cmd/server/server.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "crypto/tls" "errors" "fmt" "net/http" "net/http/httputil" "net/url" "strings" "time" "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "golang.org/x/sync/errgroup" "go.woodpecker-ci.org/woodpecker/v3/server" cron_scheduler "go.woodpecker-ci.org/woodpecker/v3/server/cron" "go.woodpecker-ci.org/woodpecker/v3/server/router" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/web" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" "go.woodpecker-ci.org/woodpecker/v3/version" ) const ( shutdownTimeout = time.Second * 5 ) var ( stopServerFunc context.CancelCauseFunc = func(error) {} shutdownCancelFunc context.CancelFunc = func() {} shutdownCtx = context.Background() ) func run(ctx context.Context, c *cli.Command) error { if err := logger.SetupGlobalLogger(ctx, c, true); err != nil { return err } ctx, ctxCancel := context.WithCancelCause(ctx) stopServerFunc = func(err error) { if err != nil { log.Error().Err(err).Msg("shutdown of whole server") } stopServerFunc = func(error) {} shutdownCtx, shutdownCancelFunc = context.WithTimeout(shutdownCtx, shutdownTimeout) ctxCancel(err) } defer stopServerFunc(nil) defer shutdownCancelFunc() // set gin mode based on log level if zerolog.GlobalLevel() > zerolog.DebugLevel { gin.SetMode(gin.ReleaseMode) } if c.String("server-host") == "" { return fmt.Errorf("WOODPECKER_HOST is not properly configured") } if !strings.Contains(c.String("server-host"), "://") { return fmt.Errorf("WOODPECKER_HOST must be :// format") } if _, err := url.Parse(c.String("server-host")); err != nil { return fmt.Errorf("could not parse WOODPECKER_HOST: %w", err) } if strings.Contains(c.String("server-host"), "://localhost") { log.Warn().Msg( "WOODPECKER_HOST should probably be publicly accessible (not localhost)", ) } _store, err := backoff.Retry(ctx, func() (store.Store, error) { return setupStore(ctx, c) }, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(c.Uint("db-max-retries")), backoff.WithNotify(func(err error, delay time.Duration) { log.Error().Msgf("failed to setup store: %v: retry in %v", err, delay) })) if err != nil { return err } defer func() { if err := _store.Close(); err != nil { log.Error().Err(err).Msg("could not close store") } }() err = setupEvilGlobals(ctx, c, _store) if err != nil { return fmt.Errorf("can't setup globals: %w", err) } // wait for all services until one do stops with an error serviceWaitingGroup := errgroup.Group{} log.Info().Msgf("starting Woodpecker server with version '%s'", version.String()) startMetricsCollector(ctx, _store) serviceWaitingGroup.Go(func() error { log.Info().Msg("starting cron service ...") if err := cron_scheduler.Run(ctx, _store); err != nil { go stopServerFunc(err) return err } log.Info().Msg("cron service stopped") return nil }) // start the grpc server serviceWaitingGroup.Go(func() error { log.Info().Msg("starting grpc server ...") if err := runGrpcServer(ctx, c, _store); err != nil { // stop whole server as grpc is essential go stopServerFunc(err) return err } return nil }) proxyWebUI := c.String("www-proxy") var webUIServe func(w http.ResponseWriter, r *http.Request) if proxyWebUI == "" { webEngine, err := web.New() if err != nil { log.Error().Err(err).Msg("failed to create web engine") return err } webUIServe = webEngine.ServeHTTP } else { origin, _ := url.Parse(proxyWebUI) director := func(req *http.Request) { req.Header.Add("X-Forwarded-Host", req.Host) req.Header.Add("X-Origin-Host", origin.Host) req.URL.Scheme = origin.Scheme req.URL.Host = origin.Host } proxy := &httputil.ReverseProxy{Director: director} webUIServe = proxy.ServeHTTP } // setup the server and start the listener handler := router.Load( webUIServe, middleware.Logger(time.RFC3339, true), middleware.Version, middleware.Store(_store), ) if c.String("server-cert") != "" { // start the server with tls enabled serviceWaitingGroup.Go(func() error { tlsServer := &http.Server{ Addr: server.Config.Server.PortTLS, Handler: handler, TLSConfig: &tls.Config{ NextProtos: []string{"h2", "http/1.1"}, }, } go func() { <-ctx.Done() log.Info().Msg("shutdown tls server ...") if err := tlsServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck log.Error().Err(err).Msg("shutdown tls server failed") } else { log.Info().Msg("tls server stopped") } }() log.Info().Msg("starting tls server ...") err := tlsServer.ListenAndServeTLS( c.String("server-cert"), c.String("server-key"), ) if err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error().Err(err).Msg("TLS server failed") stopServerFunc(fmt.Errorf("TLS server failed: %w", err)) } return err }) // http to https redirect redirect := func(w http.ResponseWriter, req *http.Request) { serverURL, _ := url.Parse(server.Config.Server.Host) req.URL.Scheme = "https" req.URL.Host = serverURL.Host w.Header().Set("Strict-Transport-Security", "max-age=31536000") http.Redirect(w, req, req.URL.String(), http.StatusMovedPermanently) } serviceWaitingGroup.Go(func() error { redirectServer := &http.Server{ Addr: server.Config.Server.Port, Handler: http.HandlerFunc(redirect), } go func() { <-ctx.Done() log.Info().Msg("shutdown redirect server ...") if err := redirectServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck log.Error().Err(err).Msg("shutdown redirect server failed") } else { log.Info().Msg("redirect server stopped") } }() log.Info().Msg("starting redirect server ...") if err := redirectServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error().Err(err).Msg("redirect server failed") stopServerFunc(fmt.Errorf("redirect server failed: %w", err)) } return nil }) } else { // start the server without tls serviceWaitingGroup.Go(func() error { httpServer := &http.Server{ Addr: c.String("server-addr"), Handler: handler, } go func() { <-ctx.Done() log.Info().Msg("shutdown http server ...") if err := httpServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck log.Error().Err(err).Msg("shutdown http server failed") } else { log.Info().Msg("http server stopped") } }() log.Info().Msg("starting http server ...") if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error().Err(err).Msg("http server failed") stopServerFunc(fmt.Errorf("http server failed: %w", err)) } return err }) } if metricsServerAddr := c.String("metrics-server-addr"); metricsServerAddr != "" { serviceWaitingGroup.Go(func() error { metricsRouter := gin.New() metricsRouter.GET("/metrics", gin.WrapH(promhttp.Handler())) metricsServer := &http.Server{ Addr: metricsServerAddr, Handler: metricsRouter, } go func() { <-ctx.Done() log.Info().Msg("shutdown metrics server ...") if err := metricsServer.Shutdown(shutdownCtx); err != nil { //nolint:contextcheck log.Error().Err(err).Msg("shutdown metrics server failed") } else { log.Info().Msg("metrics server stopped") } }() log.Info().Msg("starting metrics server ...") if err := metricsServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error().Err(err).Msg("metrics server failed") stopServerFunc(fmt.Errorf("metrics server failed: %w", err)) } return err }) } return serviceWaitingGroup.Wait() } ================================================ FILE: cmd/server/setup.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "encoding/base32" "errors" "fmt" "net/url" "os" "strings" "time" "github.com/rs/zerolog/log" "github.com/tink-crypto/tink-go/v2/subtle/random" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/cache" "go.woodpecker-ci.org/woodpecker/v3/server/forge/setup" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" "go.woodpecker-ci.org/woodpecker/v3/server/queue" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" "go.woodpecker-ci.org/woodpecker/v3/server/services" service_log "go.woodpecker-ci.org/woodpecker/v3/server/services/log" "go.woodpecker-ci.org/woodpecker/v3/server/services/log/addon" "go.woodpecker-ci.org/woodpecker/v3/server/services/log/file" "go.woodpecker-ci.org/woodpecker/v3/server/services/permissions" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/datastore" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) const ( queueInfoRefreshInterval = 500 * time.Millisecond storeInfoRefreshInterval = 10 * time.Second ) func setupStore(ctx context.Context, c *cli.Command) (store.Store, error) { datasource := c.String("db-datasource") driver := c.String("db-driver") xorm := store.XORM{ Log: c.Bool("db-log"), ShowSQL: c.Bool("db-log-sql"), MaxOpenConns: c.Int("db-max-open-connections"), MaxIdleConns: c.Int("db-max-idle-connections"), ConnMaxLifetime: c.Duration("db-max-connection-timeout"), } if driver == "sqlite3" { if datastore.SupportedDriver("sqlite3") { log.Debug().Msg("server has sqlite3 support") } else { log.Debug().Msg("server was built without sqlite3 support!") } } if !datastore.SupportedDriver(driver) { return nil, fmt.Errorf("database driver '%s' not supported", driver) } if driver == "sqlite3" { if err := checkSqliteFileExist(datasource); err != nil { return nil, fmt.Errorf("check sqlite file: %w", err) } } opts := &store.Opts{ Driver: driver, Config: datasource, XORM: xorm, } log.Debug().Str("driver", driver).Any("xorm", xorm).Msg("setting up datastore") store, err := datastore.NewEngine(opts) if err != nil { return nil, fmt.Errorf("could not open datastore: %w", err) } if err = store.Ping(); err != nil { return nil, err } if err := store.Migrate(ctx, c.Bool("migrations-allow-long")); err != nil { return nil, fmt.Errorf("could not migrate datastore: %w", err) } return store, nil } func checkSqliteFileExist(path string) error { _, err := os.Stat(path) if err != nil && os.IsNotExist(err) { log.Warn().Msgf("no sqlite3 file found, will create one at '%s'", path) return nil } return err } func setupQueue(ctx context.Context, s store.Store) (queue.Queue, error) { return queue.New(ctx, queue.Config{ Backend: queue.TypeMemory, Store: s, }) } func setupMembershipService(_ context.Context, _store store.Store) cache.MembershipService { return cache.NewMembershipService(_store) } func setupLogStore(c *cli.Command, s store.Store) (service_log.Service, error) { switch c.String("log-store") { case "file": return file.NewLogStore(c.String("log-store-file-path")) case "addon": return addon.Load(c.String("log-store-file-path")) default: return s, nil } } const jwtSecretID = "jwt-secret" func setupJWTSecret(_store store.Store) (string, error) { jwtSecret, err := _store.ServerConfigGet(jwtSecretID) if errors.Is(err, types.ErrRecordNotExist) { jwtSecret := base32.StdEncoding.EncodeToString( random.GetRandomBytes(32), ) err = _store.ServerConfigSet(jwtSecretID, jwtSecret) if err != nil { return "", err } log.Debug().Msg("created jwt secret") return jwtSecret, nil } if err != nil { return "", err } return jwtSecret, nil } func setupEvilGlobals(ctx context.Context, c *cli.Command, s store.Store) (err error) { // services server.Config.Services.Logs = logging.New() server.Config.Services.Membership = setupMembershipService(ctx, s) pubsub := memory.New() queue, err := setupQueue(ctx, s) if err != nil { return fmt.Errorf("could not setup queue: %w", err) } server.Config.Services.Scheduler = scheduler.NewScheduler(queue, pubsub) server.Config.Services.Manager, err = services.NewManager(c, s, setup.Forge) if err != nil { return fmt.Errorf("could not setup service manager: %w", err) } server.Config.Services.LogStore, err = setupLogStore(c, s) if err != nil { return fmt.Errorf("could not setup log store: %w", err) } // agents server.Config.Agent.DisableUserRegisteredAgentRegistration = c.Bool("disable-user-agent-registration") // authentication server.Config.Pipeline.AuthenticatePublicRepos = c.Bool("authenticate-public-repos") // Pull requests server.Config.Pipeline.DefaultAllowPullRequests = c.Bool("default-allow-pull-requests") // Approval mode approvalMode := model.ApprovalMode(c.String("default-approval-mode")) if !approvalMode.Valid() { return fmt.Errorf("approval mode %s is not valid", approvalMode) } server.Config.Pipeline.DefaultApprovalMode = approvalMode // Cloning server.Config.Pipeline.DefaultClonePlugin = c.String("default-clone-plugin") server.Config.Pipeline.TrustedClonePlugins = c.StringSlice("plugins-trusted-clone") server.Config.Pipeline.TrustedClonePlugins = append(server.Config.Pipeline.TrustedClonePlugins, server.Config.Pipeline.DefaultClonePlugin) // Execution _events := c.StringSlice("default-cancel-previous-pipeline-events") events := make([]model.WebhookEvent, 0, len(_events)) for _, v := range _events { e := model.WebhookEvent(v) if err := e.Validate(); err != nil { return err } events = append(events, e) } server.Config.Pipeline.DefaultCancelPreviousPipelineEvents = events server.Config.Pipeline.DefaultTimeout = c.Int64("default-pipeline-timeout") server.Config.Pipeline.MaxTimeout = c.Int64("max-pipeline-timeout") _labels := c.StringSlice("default-workflow-labels") labels := make(map[string]string, len(_labels)) for _, v := range _labels { name, value, ok := strings.Cut(v, "=") if !ok { return fmt.Errorf("invalid label filter: %s", v) } labels[name] = value } server.Config.Pipeline.DefaultWorkflowLabels = labels // backend options for pipeline compiler server.Config.Pipeline.Proxy.No = c.String("backend-no-proxy") server.Config.Pipeline.Proxy.HTTP = c.String("backend-http-proxy") server.Config.Pipeline.Proxy.HTTPS = c.String("backend-https-proxy") // server configuration server.Config.Server.JWTSecret, err = setupJWTSecret(s) if err != nil { return fmt.Errorf("could not setup jwt secret: %w", err) } server.Config.Server.Cert = c.String("server-cert") server.Config.Server.Key = c.String("server-key") server.Config.Server.AgentToken = c.String("agent-secret") serverHost := strings.TrimSuffix(c.String("server-host"), "/") server.Config.Server.Host = serverHost if c.IsSet("server-webhook-host") { server.Config.Server.WebhookHost = c.String("server-webhook-host") } else { server.Config.Server.WebhookHost = serverHost } server.Config.Server.OAuthHost = serverHost server.Config.Server.Port = c.String("server-addr") server.Config.Server.PortTLS = c.String("server-addr-tls") server.Config.Server.StatusContext = c.String("status-context") server.Config.Server.StatusContextFormat = c.String("status-context-format") server.Config.Server.SessionExpires = c.Duration("session-expires") u, _ := url.Parse(server.Config.Server.Host) rootPath := strings.TrimSuffix(u.Path, "/") if rootPath != "" && !strings.HasPrefix(rootPath, "/") { rootPath = "/" + rootPath } server.Config.Server.RootPath = rootPath server.Config.Server.CustomCSSFile = strings.TrimSpace(c.String("custom-css-file")) server.Config.Server.CustomJsFile = strings.TrimSpace(c.String("custom-js-file")) server.Config.Pipeline.Networks = c.StringSlice("network") server.Config.Pipeline.Volumes = c.StringSlice("volume") server.Config.WebUI.EnableSwagger = c.Bool("enable-swagger") server.Config.WebUI.SkipVersionCheck = c.Bool("skip-version-check") server.Config.WebUI.MaxPipelineLogLineCount = c.Uint("max-pipeline-log-line-count") server.Config.Pipeline.PrivilegedPlugins = c.StringSlice("plugins-privileged") // TODO: remove with version 4.x server.Config.Pipeline.ForceIgnoreServiceFailure = c.Bool("force-ignore-service-failure") if server.Config.Pipeline.ForceIgnoreServiceFailure { log.Info().Msg("WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE is true by default. To prepare for v4.0.0, set it to false and update your pipeline definitions if needed.") } // prometheus server.Config.Prometheus.AuthToken = c.String("prometheus-auth-token") // permissions server.Config.Permissions.Open = c.Bool("open") server.Config.Permissions.Admins = permissions.NewAdmins(c.StringSlice("admin")) server.Config.Permissions.Orgs = permissions.NewOrgs(c.StringSlice("orgs")) server.Config.Permissions.OwnersAllowlist = permissions.NewOwnersAllowlist(c.StringSlice("repo-owners")) return nil } ================================================ FILE: codecov.yaml ================================================ ignore: - '**/mocks/mock_*.go' - '**/fixtures/*.go' - 'e2e/**/*.go' ================================================ FILE: contrib/woodpecker-test-repo/.woodpecker/demo.yaml ================================================ steps: demo: image: 'alpine' commands: - echo 'Demo' ================================================ FILE: contrib/woodpecker-test-repo/.woodpecker/test.yaml ================================================ steps: test_1: image: 'alpine' commands: - echo 'Test 1' test_2: image: 'alpine' commands: - echo 'Test 2' test_3: image: 'alpine' commands: - echo 'Test 3' ================================================ FILE: docker/Dockerfile.agent.alpine.multiarch ================================================ FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build WORKDIR /src COPY . . ARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ make build-agent FROM docker.io/alpine:3.23 RUN apk add -U --no-cache ca-certificates && \ adduser -u 1000 -g 1000 woodpecker -D && \ mkdir -p /etc/woodpecker && \ chown -R woodpecker:woodpecker /etc/woodpecker ENV GODEBUG=netdns=go # Internal setting do NOT change! Signals that woodpecker is running inside a container ENV WOODPECKER_IN_CONTAINER=true EXPOSE 3000 COPY --from=build /src/dist/woodpecker-agent /bin/ HEALTHCHECK CMD ["/bin/woodpecker-agent", "ping"] ENTRYPOINT ["/bin/woodpecker-agent"] ================================================ FILE: docker/Dockerfile.agent.multiarch ================================================ FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build RUN groupadd -g 1000 woodpecker && \ useradd -u 1000 -g 1000 woodpecker && \ mkdir -p /etc/woodpecker WORKDIR /src COPY . . ARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ make build-agent FROM scratch ENV GODEBUG=netdns=go # Internal setting do NOT change! Signals that woodpecker is running inside a container ENV WOODPECKER_IN_CONTAINER=true EXPOSE 3000 # copy certs from build image COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # copy agent binary COPY --from=build /src/dist/woodpecker-agent /bin/ COPY --from=build --chown=woodpecker:woodpecker /etc/woodpecker /etc COPY --from=build /etc/passwd /etc/passwd COPY --from=build /etc/group /etc/group HEALTHCHECK CMD ["/bin/woodpecker-agent", "ping"] ENTRYPOINT ["/bin/woodpecker-agent"] ================================================ FILE: docker/Dockerfile.cli.alpine.multiarch.rootless ================================================ FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build WORKDIR /src COPY . . ARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ make build-cli FROM docker.io/alpine:3.23 WORKDIR /woodpecker RUN apk add -U --no-cache ca-certificates && \ adduser -u 1000 -g 1000 -D woodpecker ENV GODEBUG=netdns=go ENV WOODPECKER_DISABLE_UPDATE_CHECK=true COPY --from=build /src/dist/woodpecker-cli /bin/ USER woodpecker HEALTHCHECK CMD ["/bin/woodpecker-cli", "ping"] ENTRYPOINT ["/bin/woodpecker-cli"] ================================================ FILE: docker/Dockerfile.cli.multiarch.rootless ================================================ FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build RUN groupadd -g 1000 woodpecker && \ useradd -u 1000 -g 1000 woodpecker WORKDIR /src COPY . . ARG TARGETOS TARGETARCH CI_COMMIT_SHA CI_COMMIT_TAG CI_COMMIT_BRANCH RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ make build-cli FROM scratch WORKDIR /woodpecker ENV GODEBUG=netdns=go ENV WOODPECKER_DISABLE_UPDATE_CHECK=true # copy certs from build image COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # copy cli binary COPY --from=build /src/dist/woodpecker-cli /bin/ COPY --from=build /etc/passwd /etc/passwd COPY --from=build /etc/group /etc/group USER woodpecker HEALTHCHECK CMD ["/bin/woodpecker-cli", "ping"] ENTRYPOINT ["/bin/woodpecker-cli"] ================================================ FILE: docker/Dockerfile.make ================================================ # docker build --rm -f docker/Dockerfile.make -t woodpecker/make:local . FROM docker.io/golang:1.26-alpine AS golang_image FROM docker.io/node:24-alpine RUN apk add --no-cache --update make gcc binutils-gold musl-dev && \ apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main protoc && \ corepack enable # Build packages. COPY --from=golang_image /usr/local/go /usr/local/go COPY Makefile / ENV PATH=$PATH:/usr/local/go/bin ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 ENV COREPACK_ENABLE_AUTO_PIN=0 # Cache tools RUN GOBIN=/usr/local/go/bin make install-tools && \ rm -rf /Makefile ENV GOPATH=/tmp/go ENV HOME=/tmp/home ENV PATH=$PATH:/usr/local/go/bin:/tmp/go/bin WORKDIR /build RUN chmod -R 777 /root CMD [ "/bin/sh" ] ================================================ FILE: docker/Dockerfile.server.alpine.multiarch.rootless ================================================ FROM docker.io/alpine:3.23 ARG TARGETOS TARGETARCH RUN apk add -U --no-cache ca-certificates && \ adduser -u 1000 -g 1000 woodpecker -D && \ mkdir -p /var/lib/woodpecker && \ chown -R woodpecker:woodpecker /var/lib/woodpecker ENV GODEBUG=netdns=go # Internal setting do NOT change! Signals that woodpecker is running inside a container ENV WOODPECKER_IN_CONTAINER=true ENV XDG_CACHE_HOME=/var/lib/woodpecker ENV XDG_DATA_HOME=/var/lib/woodpecker EXPOSE 8000 9000 80 443 COPY dist/server/${TARGETOS}_${TARGETARCH}/woodpecker-server /bin/ USER woodpecker HEALTHCHECK CMD ["/bin/woodpecker-server", "ping"] ENTRYPOINT ["/bin/woodpecker-server"] ================================================ FILE: docker/Dockerfile.server.multiarch.rootless ================================================ FROM --platform=$BUILDPLATFORM docker.io/golang:1.26 AS build RUN groupadd -g 1000 woodpecker && \ useradd -u 1000 -g 1000 woodpecker && \ mkdir -p /var/lib/woodpecker FROM scratch ARG TARGETOS TARGETARCH ENV GODEBUG=netdns=go # Internal setting do NOT change! Signals that woodpecker is running inside a container ENV WOODPECKER_IN_CONTAINER=true ENV XDG_CACHE_HOME=/var/lib/woodpecker ENV XDG_DATA_HOME=/var/lib/woodpecker EXPOSE 8000 9000 80 443 # copy certs from certs image COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # copy server binary COPY dist/server/${TARGETOS}_${TARGETARCH}/woodpecker-server /bin/ COPY --from=build /etc/passwd /etc/passwd COPY --from=build /etc/group /etc/group COPY --from=build --chown=woodpecker:woodpecker /var/lib/woodpecker /var/lib/woodpecker USER woodpecker HEALTHCHECK CMD ["/bin/woodpecker-server", "ping"] ENTRYPOINT ["/bin/woodpecker-server"] ================================================ FILE: docker-compose.example.yaml ================================================ version: '3' services: woodpecker-server: image: woodpeckerci/woodpecker-server:v3 ports: - 8000:8000 networks: - woodpecker volumes: - /var/lib/woodpecker:/var/lib/woodpecker/ environment: - WOODPECKER_OPEN=true - WOODPECKER_ADMIN=laszlocph - WOODPECKER_HOST=${WOODPECKER_HOST} - WOODPECKER_GITHUB=true - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: depends_on: woodpecker-server: condition: service_healthy image: woodpeckerci/woodpecker-agent:v3 networks: - woodpecker volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - WOODPECKER_SERVER=woodpecker-server:9000 - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} - WOODPECKER_MAX_WORKFLOWS=2 networks: woodpecker: ================================================ FILE: docker-compose.gitpod.yaml ================================================ # cSpell:ignore pgdata pgsql localtime version: '3' services: gitea-database: image: postgres:18.3-alpine environment: POSTGRES_USER: gitea POSTGRES_PASSWORD: 123456 POSTGRES_DB: gitea PGDATA: /var/lib/postgresql/data/pgdata volumes: - pgsql:/var/lib/postgresql/data/pgdata gitea: image: gitea/gitea:1.26 ports: - 3000:3000 volumes: - gitea:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro depends_on: - gitea-database environment: USER_UID: 1000 USER_GID: 1000 # GITEA__server__DOMAIN: gitea.local.self GITEA__server__ROOT_URL: https://3000-${GITPOD_WORKSPACE_ID}.${GITPOD_WORKSPACE_CLUSTER_HOST} GITEA__database__DB_TYPE: postgres GITEA__database__HOST: gitea-database:5432 GITEA__database__NAME: gitea GITEA__database__USER: gitea GITEA__database__PASSWD: 123456 GITEA__webhook__ALLOWED_HOST_LIST: '*' GITEA__security__INSTALL_LOCK: 'true' GITEA__security__INTERNAL_TOKEN: '123456' extra_hosts: - 'host.docker.internal:host-gateway' volumes: gitea: pgsql: ================================================ FILE: docs/.gitignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: docs/.prettierignore ================================================ pnpm-lock.yaml dist LICENSE openapi.json 40-cli.md build/ ================================================ FILE: docs/.prettierrc.js ================================================ const config = require('../.prettierrc.json'); module.exports = { ...config, plugins: ['@ianvs/prettier-plugin-sort-imports'], importOrder: [ '', // Imports not matched by other special words or groups. '', // Empty string will match any import not matched by other special words or groups. '^(#|@|~|\\$)(/.*)$', '', '^[./]', ], }; ================================================ FILE: docs/LICENSE ================================================ Files in this folder are licensed under Creative Commons Attribution-ShareAlike 4.0 International Public License. It is a derivative work of the https://github.com/drone/docs git repository. Attribution-ShareAlike 4.0 International ======================================================================= Creative Commons Corporation ("Creative Commons") is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an "as-is" basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. Using Creative Commons Public Licenses Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC- licensed material, or material used under an exception or limitation to copyright. More considerations for licensors: wiki.creativecommons.org/Considerations_for_licensors Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor's permission is not necessary for any reason--for example, because of any applicable exception or limitation to copyright--then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public: wiki.creativecommons.org/Considerations_for_licensees ======================================================================= Creative Commons Attribution-ShareAlike 4.0 International Public License By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. Section 1 -- Definitions. a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License. d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. j. Licensor means the individual(s) or entity(ies) granting rights under this Public License. k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. Section 2 -- Scope. a. License grant. 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: a. reproduce and Share the Licensed Material, in whole or in part; and b. produce, reproduce, and Share Adapted Material. 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 3. Term. The term of this Public License is specified in Section 6(a). 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a) (4) never produces Adapted Material. 5. Downstream recipients. a. Offer from the Licensor -- Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. b. Additional offer from the Licensor -- Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply. c. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). b. Other rights. 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 2. Patent and trademark rights are not licensed under this Public License. 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. Section 3 -- License Conditions. Your exercise of the Licensed Rights is expressly made subject to the following conditions. a. Attribution. 1. If You Share the Licensed Material (including in modified form), You must: a. retain the following if it is supplied by the Licensor with the Licensed Material: i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); ii. a copyright notice; iii. a notice that refers to this Public License; iv. a notice that refers to the disclaimer of warranties; v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; b. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and c. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. b. ShareAlike. In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. 1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. 2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. 3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. Section 4 -- Sui Generis Database Rights. Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. Section 5 -- Disclaimer of Warranties and Limitation of Liability. a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. Section 6 -- Term and Termination. a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. Section 7 -- Other Terms and Conditions. a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. Section 8 -- Interpretation. a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. ======================================================================= Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the CC0 Public Domain Dedication. Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark "Creative Commons" or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. Creative Commons may be contacted at creativecommons.org. ================================================ FILE: docs/README.md ================================================ # Website This website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator. ## Installation ```bash pnpm install ``` ## Local Development ```bash pnpm start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ## Build ```bash pnpm build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ## Deployment Deployment happen via [CI](https://github.com/woodpecker-ci/woodpecker/blob/d59fdb4602bfdd0d00078716ba61b05c02cbd1af/.woodpecker/docs.yml#L8-L30) to [woodpecker-ci.org](https://woodpecker-ci.org). To manually build the website and push it exec: ```sh GIT_USER=woodpecker-bot USE_SSH=true DEPLOYMENT_BRANCH=main pnpm deploy ``` ================================================ FILE: docs/blog/2023-06-11-hello-blog/index.md ================================================ --- title: Welcome Woodpecker's blog description: This our first post on Woodpecker slug: hello-blog authors: - name: Anbraten title: Maintainer of Woodpecker url: https://github.com/anbraten image_url: https://github.com/anbraten.png tags: [hello, woodpecker] hide_table_of_contents: false --- Welcome to this blog. This is our first post on this blog ... In the future we will post about our releases and other things like tutorials. We are currently working on the `1.0.0` release of Woodpecker. This release will include a lot of new features and improvements which most of you probably already tested using the `next` tag. If you have any suggestions or ideas for posts, feel free to open an issue in the [GitHub repository](https://github.com/woodpecker-ci/woodpecker). ================================================ FILE: docs/blog/2023-07-28-release-v1.0.0/index.md ================================================ --- title: Presenting Woodpecker 1.0.0 description: Introducing Woodpecker 1.0.0 and its new features. slug: release-v100 authors: - name: 6543 title: Maintainer of Woodpecker url: https://github.com/6543 image_url: https://github.com/6543.png tags: [release, major] hide_table_of_contents: false --- We are proud to present you Woodpecker v1.0.0. It took us quite some time, but now we are sure it's ready, and you should really have a look at it. We've refactored a lot of code, so contributing to the codebase should be much easier. Furthermore, a ton of bugs where addressed and various enhancements introduced, along with some highly anticipated features. With Woodpecker v1.0.0, you can now substantially improve and streamline your code pipelines, empowering you to automate and optimize workflows like never before. ## Some picked highlights ### Add Support for Cron Jobs Automate recurring tasks with ease using Woodpecker's new cron jobs feature. Schedule pipelines to run at specified intervals or times, optimizing repetitive workflows. ### YAML Map Merge, Overrides, and Sequence Merge Support With enhanced YAML support, managing complex configurations becomes a breeze. Merge maps, apply overrides, and sequence merging—all within your YAML files. This is providing more flexibility and control over your pipelines. ### Web-UI for Admins Simplify administration tasks with Woodpecker's new Admin UI. Effortlessly manage user accounts, agents, and tasks, including adding new agents or pausing the task queue for maintenance. ![Image of admin queue view](./admin_queue_ui.png) ### Localize Web-UI Embrace internationalization by changing your locale in the user settings. Interact with Woodpecker in the language of your choice, tailored to your preferences. If your language is not available or only partially translated, consider contributing to our [Weblate](https://translate.woodpecker-ci.org/engage/woodpecker-ci/). ### Add `evaluate` to `when` Filter Enhance pipeline flexibility with the new "when evaluate" filter, enabling or disabling steps based on custom conditions. Customize your workflows to dynamically respond to specific triggers and events. ### Global- and Organization-Secrets Save time and effort by declaring secrets for your entire instance or organization. Simplify your workflow and securely manage sensitive information across projects. ![Image of settings view of org secrets](./org_secrets.png) ## Changelog The full changelog can be viewed in our project source folder at [CHANGELOG.md](https://github.com/woodpecker-ci/woodpecker/blob/v1.0.0/CHANGELOG.md) ================================================ FILE: docs/blog/2023-11-09-release-v2.0.0/index.md ================================================ --- title: It's time for some changes - Woodpecker 2.0.0 description: Introducing Woodpecker 2.0.0 with more than 350 changes slug: release-v200 authors: - name: Anbraten title: Maintainer of Woodpecker url: https://github.com/anbraten image_url: https://github.com/anbraten.png - name: qwerty287 title: Maintainer of Woodpecker url: https://github.com/qwerty287 image_url: https://github.com/qwerty287.png tags: [release, major] hide_table_of_contents: false --- We are proud to present you Woodpecker v2.0.0 with more than 350 changes from our fabulous community. This release includes a lot of new features, improvements and some breaking changes which most of you probably already tested using the `next` tag or the RC version. ## How we plan to handle releases in the future In the future, there won't be backports anymore as they require quite an amount of maintenance. Instead, we'll release our current state of the `main` branch with the correct version (according to semver) every few weeks. Of course, critical bug and security fixes are released as soon as possible. To not release new major version too often, we'll try to hold back breaking changes pull-request for a longer time and release them all together in a new major version. ## Breaking changes ### Renamed some api routes We renamed some API routes to be more consistent. So we suggest admins to update all repository webhooks by clicking on the newly added `Repair all repositories` button in the admin settings. ### Dropped deprecated environment variables and CLI commands For v1.0.0, we deprecated a bunch of old environment variables like `CI_BUILD_*`. These variables were removed in this version, you therefore have to use the new ones. Also, the deprecated `build` command of the CLI was removed. Use `pipeline` instead. ### Removed SSH backend Due to various issues with the SSH backend we decided to remove it. As an alternative, you can install an agent running the local backend directly on the remote machine or you can simply execute `ssh` commands connecting to the remote server in your pipeline. ### Deprecated `platform` filter The `platform` filter has been removed. Use the more advanced labels instead ([read more](../docs/usage/workflow-syntax#filter-by-platform)). ### Update Docker to v24 We updated Docker to v24 as of some security patches. If you use an older version of Docker, you might need to upgrade it. ### Removed plugin-only option from secrets Security is pretty important to us and we want to make sure that no one can steal your secrets. Therefore, we decided to remove the plugin-only option from secrets and instead, if you define an image filter, it will be automatically only available to plugins using the defined image names. ## Migration notes There have been a few more breaking changes. [Read more about what you need to do when upgrading!](/migrations#200) ## New features But that's enough about breaking changes. Let's talk about the new features! ### Config warnings and errors in the UI You ever wondered why a secret was not working and after hours of debugging you found out that you misspelled the secret name? Or you used a wrong key in your YAML config? Woodpecker now shows errors and linter warnings directly in it's UI, notifying you about missing secrets, incorrect configuration or deprecated settings! ![Image of warnings and errors in the UI](./linter_warnings_errors.png) ### Repository and organization overview for admins Admins now get an overview over all repositories and organizations registered on the server, allowing them to perform common actions like deleting directly from the admin dashboard. ![Image of repos overview](./admin_repos.png) ### Support for user secrets It is now possible to add secrets for all repos owned by yourself, similar to organization and global secrets. ### Bitbucket cloud support for multi-workflows We enhanced support for Bitbucket, allowing you to use multiple workflows just as you probably know from all other forges already. ### Full support for Kubernetes backend Many of you already used it extensively in the past, but now we can finally call the Kubernetes backend ready for production use. Supporting all major features and even quite some Kubernetes specific options. ### Auto theme The UI now supports automatically adapting the theme to your browser config, so no more light mode in the middle of the night! ### Update notification Updates are awesome as they bring new features and bug fixes most of the time, but sometimes there are also important security fixes which should be installed as soon as possible. To not miss any of them, we added a notification to the UI for admins if there's a new update available. ## Changelog The full changelog can be viewed in our project source folder at [CHANGELOG.md](https://github.com/woodpecker-ci/woodpecker/blob/v2.0.0/CHANGELOG.md) ================================================ FILE: docs/blog/2023-12-12-podman-image-builds/index.md ================================================ --- title: '[Community] Podman-in-Podman image builds' description: Build images in Podman with buildah slug: podman-image-builds authors: - name: handlebargh url: https://github.com/handlebargh image_url: https://github.com/handlebargh.png hide_table_of_contents: true tags: [community, image, podman] --- I run Woodpecker CI with podman backend instead of docker and just figured out how to build images with buildah. Since I couldn't find this anywhere documented, I thought I might as well just share it here. It's actually pretty straight forward. Here's what my repository structure looks like: ```bash . ├── roundcube │   ├── Containerfile │   ├── docker-entrypoint.sh │   └── php.ini └── .woodpecker └── .build_roundcube.yml ``` As you can see I'm building a roundcube mail image. This is the `.woodpecker/.build_roundcube.yaml` ```yaml when: event: [cron, manual] cron: build_roundcube steps: build-image: image: quay.io/buildah/stable:latest pull: true privileged: true commands: - echo $REGISTRY_LOGIN_TOKEN | buildah login -u --password-stdin registry.gitlab.com - cd roundcube - buildah build --tag registry.gitlab.com///roundcube:latest . - buildah push registry.gitlab.com///roundcube:latest secrets: [registry_login_token] ``` As you can see, I'm using this workflow over at gitlab.com. It should work with GitHub as well, with adjusting the registry login. You may have to adjust the `when:` to your needs. Furthermore, you must check the `trusted` checkbox in project settings. Therefore, be sure to run trusted code only in this setup. This seems to work fine so far. I wonder if anybody else made this work a different way. EDIT: Removed the additional step that would run buildah in a podman container. I didn't know it could be that easy to be honest. ================================================ FILE: docs/blog/2023-12-13-debug-pipeline-steps/index.md ================================================ --- title: '[Community] Debug pipeline steps' description: Debug pipeline steps using sshx slug: debug-pipeline-steps authors: - name: anbraten url: https://github.com/anbraten image_url: https://github.com/anbraten.png hide_table_of_contents: true tags: [community, debug] --- Sometimes you want to debug a pipeline. Therefore I recently discovered: A simple step like should allow you to debug: ```yaml steps: - name: debug image: alpine commands: - curl -sSf https://sshx.io/get | sh && sshx # ^ # └ This will open a remote terminal session and print the URL. It # should take under a second. ``` ================================================ FILE: docs/blog/2023-12-15-podman-sigstore/index.md ================================================ --- title: '[Community] Podman image build with sigstore' description: Build images in Podman with sigstore signature checking and signing slug: podman-image-build-sigstore authors: - name: handlebargh url: https://github.com/handlebargh image_url: https://github.com/handlebargh.png hide_table_of_contents: false tags: [community, image, podman, sigstore, signature] --- This example shows how to build a container image with podman while verifying the base image and signing the resulting image. The image being pulled uses a keyless signature, while the image being built will be signed by a pre-generated private key. ## Prerequisites ### Generate signing keypair You can use cosing or skopeo to generate the keypair. Using skopeo: ```bash skopeo generate-sigstore-key --output-prefix myKey ``` This command will generate a `myKey.private` and a `myKey.pub` keyfile. Store the `myKey.private` as secret in Woodpecker. In the example below, the secret is called `sigstore_private_key` ### Configure hosts pulling the resulting image See [here](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/building_running_and_managing_containers/assembly_signing-container-images_building-running-and-managing-containers#proc_verifying-sigstore-image-signatures-using-a-public-key_assembly_signing-container-images) on how to configure the hosts pulling the built and signed image. ## Repository structure Consider the `Makefile` having a `build` target that will be used in the following workflow. This target yields a Go binary with the filename `app` that will be placed in the root directory. ```bash . ├── Containerfile ├── main.go ├── go.mod ├── go.sum ├── .woodpecker.yml └── Makefile ``` ### Containerfile The Containerfile refers to the base image that will be verified when pulled. ```dockerfile FROM gcr.io/distroless/static-debian12:nonroot COPY app /app CMD ["/app"] ``` ### Woodpecker workflow ```yaml steps: build: image: docker.io/library/golang:1.21 pull: true commands: - make build publish: image: quay.io/podman/stable:latest # Caution: This image is built daily. It might fill up your image store quickly. pull: true # Fill in the trusted checkbox in Woodpecker's settings as well privileged: true commands: # Configure podman to use sigstore attachments for both, the registry you pull from and the registry you push to. - | printf "docker: registry.gitlab.com: use-sigstore-attachments: true gcr.io: use-sigstore-attachments: true" >> /etc/containers/registries.d/default.yaml # At pull, check the keyless sigstore signature of the distroless image. # This is a very strict container policy. It allows pulling from gcr.io/distroless only. Every other registry will be rejected. # See https://github.com/containers/image/blob/main/docs/containers-policy.json.5.md for more information. # fulcio CA crt obtained from https://github.com/sigstore/sigstore/blob/main/pkg/tuf/repository/targets/fulcio_v1.crt.pem # rekor public key obtained from https://github.com/sigstore/sigstore/blob/main/pkg/tuf/repository/targets/rekor.pub # crt/key data is base64 encoded. --> echo "$CERT" | base64 - | printf '{ "default": [ { "type": "reject" } ], "transports": { "docker": { "gcr.io/distroless": [ { "type": "sigstoreSigned", "fulcio": { "caData": "LS0tLS1CRUdJTiBDR...QVRFLS0tLS0K", "oidcIssuer": "https://accounts.google.com", "subjectEmail": "keyless@distroless.iam.gserviceaccount.com" }, "rekorPublicKeyData": "LS0tLS1CRUdJTiBQVUJ...lDIEtFWS0tLS0tCg==", "signedIdentity": { "type": "matchRepository" } } ] }, "docker-daemon": { "": [ { "type": "reject" } ] } } }' > /etc/containers/policy.json # Use this key to sign the built image at push. - echo "$SIGSTORE_PRIVATE_KEY" > key.private # Login at the registry - echo $REGISTRY_LOGIN_TOKEN | podman login -u --password-stdin registry.gitlab.com # Build the container image - podman build --tag registry.gitlab.com///:latest . # Sign and push the image - podman push --sign-by-sigstore-private-key ./key.private registry.gitlab.com///:latest secrets: [sigstore_private_key, registry_login_token] ``` ================================================ FILE: docs/blog/2024-01-01-continuous-deployment/index.md ================================================ --- title: '[Community] Continuous Deployment' description: Deploy your artifacts to an app server slug: continuous-deployment authors: - name: lonix1 url: https://github.com/lonix1 image_url: https://github.com/lonix1.png hide_table_of_contents: false tags: [community, cd, deployment] --- A typical CI pipeline contains steps such as: _clone_, _build_, _test_, _package_ and _push_. The final build product may be artifacts pushed to a git repository or a docker container pushed to a container registry. When these should be deployed on an app server, the pipeline should include a _deploy_ step, which represents the "CD" in CI/CD - the automatic deployment of a pipeline's final product. There are various ways to accomplish CD with Woodpecker, depending on your project's specific needs. ## Invoking deploy script via SSH The final step in your pipeline could SSH into the app server and run a deployment script. One of the benefits would be that the deployment script's output could be included in the pipeline's log. However in general, this is a complicated option as it tightly couples the CI and app servers. An SSH step could be written by using a plugin, like [ssh](https://plugins.drone.io/plugins/ssh) or [git push](https://woodpecker-ci.org/plugins/git-push). ## Polling for asset changes This option completely decouples the CI and app servers, and there is no explicit deploy step in the pipeline. On the app server, one should create a script or cron job that polls for asset changes (every minute, say). When a new version is detected, the script redeploys the app. This option is easy to maintain, but the downside is a short delay (one minute) before new assets are detected. ## Using a configuration management tool If you are using a configuration management tool (e.g. Ansible, Chef, Puppet), then you could setup the last pipeline step to call that tool to perform the redeployment. A plugin for [Ansible](https://woodpecker-ci.org/plugins/ansible) exists and could be adapted accordingly. This option is complex and only suitable in an environment in which you're already using configuration management. ## Using webhooks (recommended) If your forge (GitHub, GitLab, Gitea, etc.) supports webhooks, then you could create a separate listening app that receives a webhook when new assets are available and redeploys your app. The listening "app" can be something as simple as a PHP script. Alternatively, there are a number of popular webhook servers that simplify this process, so you only need to write your actual deployment script. For example, [webhook](https://github.com/adnanh/webhook) and [webhookd](https://github.com/ncarlier/webhookd). This is arguably the simplest and most maintainable solution. ================================================ FILE: docs/blog/2024-05-27-release-v2.5.0/index.md ================================================ --- title: Here is Woodpecker 2.5.0 description: Introducing Woodpecker 2.5.0 slug: release-v250 authors: - name: Anbraten title: Maintainer of Woodpecker url: https://github.com/anbraten image_url: https://github.com/anbraten.png tags: [release, minor] hide_table_of_contents: false --- Here is the next minor release 2.5.0 of Woodpecker 🪶 ☀️. As always thanks to all contributors who helped to make this release possible. It includes quite a few enhancements most users will benefit from while they are probably not that visible at first sight for most. The release also includes some preparations for new features to come in the next versions. Anyway, let's dive into some of the highlights of this release: ## Improve the way entrypoints work The implementation wasn't perfect yet so we improved the way entrypoints work: If you define [`commands`](/docs/usage/workflow-syntax#commands), the default entrypoint will be `["/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"]`. If you define your own entrypoint, you can completely overwrite the default entrypoint. If you define `entrypoint: ["/bin/my-script", ""]` for example you can run your own binary / script. In this case the commands section will ignored, however you can still access it in your own script by using the base64 encoded string of the `CI_SCRIPT` environment variable. [#3269](https://github.com/woodpecker-ci/woodpecker/pull/3269) ## Cli output formats The cli output has been improved. The first command (mainly pipeline info, ls, create) support a `--output` flag now which allows you to change the output format. There is a new `table` format (the new default) which will look like the following and can be further customized: ```bash # use default table output ❯ woodpecker-cli pipeline ls --limit 2 2 NUMBER STATUS EVENT BRANCH COMMIT AUTHOR 43 error manual main 473761d8b26b20f7c206408563d54cf998410329 woodpecker 42 success push main 473761d8b26b20f7c206408563d54cf998410329 woodpecker # customize table output and disable header ❯ woodpecker-cli pipeline ls --limit 2 --output table=number,status,event --no-header 2 43 error manual 42 success push ``` In addition especially useful for programmatic usage there is a `go-template` output format which will output the data using the provided go template like this: ```bash ######## # go crazy and use a template layout ❯ woodpecker-cli pipeline ls --limit 2 --output go-template='{{range .}}{{printf "\x1b[33mPipeline #%d\x1b[0m\nStatus: %s\nEvent:%s\nCommit:%s\n\n" .Number .Status .Event .Commit}}{{end}}' 2 Pipeline #43 Status: error Event:manual Commit:473761d8b26b20f7c206408563d54cf998410329 Pipeline #42 Status: success Event:push Commit:473761d8b26b20f7c206408563d54cf998410329 ``` [#3660](https://github.com/woodpecker-ci/woodpecker/pull/3660) ## Deleting logs or complete pipelines If you accidentally exposed some secret to the public in your logs or you simply want to cleanup some logs you can now delete logs or complete pipelines using the api and the cli. [#3451](https://github.com/woodpecker-ci/woodpecker/pull/3451) [#3506](https://github.com/woodpecker-ci/woodpecker/pull/3506) [#3458](https://github.com/woodpecker-ci/woodpecker/pull/3458) ## Support for Github deploy tasks Woodpecker now supports Github deploy tasks. This allows you to pass the deploy task set in Github to your Woodpecker pipeline. [#3512](https://github.com/woodpecker-ci/woodpecker/pull/3512) ## Deprecations To keep things clean and simple we deprecated some pipeline options, server settings and features which will be removed in the next major release: - Deprecated `environment` filter, use `when.evaluate` - Use `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` instead of `WOODPECKER_DEV_GITEA_OAUTH_URL` or `WOODPECKER_DEV_OAUTH_HOST` - Deprecated `WOODPECKER_WEBHOOK_HOST` in favor of `WOODPECKER_EXPERT_WEBHOOK_HOST` For a full list of deprecations that will be dropped in the `next` major release `3.0.0` (no eta yet), please check the [migrations](/migrations#next) section. ================================================ FILE: docs/blog/2024-12-28-release-v3.0.0/index.md ================================================ --- title: Woodpecker 3.0.0 description: Introducing Woodpecker 2.5.0 slug: release-v300 authors: - name: pat-s title: Maintainer of Woodpecker url: https://github.com/pat-s image_url: https://github.com/pat-s.png tags: [release, major] hide_table_of_contents: false --- We are excited to announce the release of Woodpecker 3.0.0! Along with various cleanup improvements, you can now register your own agents as a user and replay pipelines directly from the server using cli exec. ## Breaking Changes To enhance the usability of Woodpecker and comply with evolving security standards, we periodically implement migrations. While we strive to minimize changes, some adjustments are essential for an improved user experience. We acknowledge that this release includes a significant number of changes, many of which require users to update their pipeline definitions. We understand that this can be a tedious task, especially when managing multiple repositories and pipelines. Rest assured that each modification was carefully considered and thoroughly discussed, with specific reasoning behind every decision. A substantial portion of these updates aims to transition away from outdated and suboptimal Drone definitions. Your patience and understanding as we implement these necessary changes are greatly appreciated. If you encounter any major issues during your migration to a new version, please don't hesitate to reach out. The Woodpecker maintainers are always eager to reassess and improve our updates based on your feedback. Security has been a primary focus in this major release. In addition to patching known vulnerabilities (which have also been backported to v2 releases), we have enhanced the secrets handling mechanism to prevent accidental leaks and simplify the process of keeping sensitive information fully encrypted. For a complete list of migration steps, please refer to the [migration guide](/migrations). ### `from_secret:` as the powerful replacement for the `secrets:` keyword Specifically, the `secrets:` keyword has been deprecated in favor of a more flexible (and secure) way to specify secrets: `from_secret:`. This new approach provides more flexibility (by using different names for the source and destination secrets) and ensures a safe internal secret parsing through a unified engine. Because secrets defined via `secrets:` were simple env vars in the end, this change also removes potential confusion about the differences between values specified in `environment:` and `secrets`. Now, both are defined in `environment:` using an expressive syntax: ```yaml steps: name: image: alpine commands: - echo "The secret is $TOKEN_ENV" environment: TOKEN_ENV: from_secret: SECRET_TOKEN ``` ## Register Your Own Agents for Users or Organizations [#3539](https://github.com/woodpecker-ci/woodpecker/pull/3539) WoodpeckerCI now lets you register custom agents scoped to individual users or organizations. This means you can bring your own agents, configured to meet the unique needs of your projects, and assign them to specific users or organizational workflows. This update provides flexibility for teams with diverse requirements, allowing them to integrate agents tailored to specific tasks or environments seamlessly into their pipelines. ## Replay Pipelines Locally Using `cli exec` [#4103](https://github.com/woodpecker-ci/woodpecker/pull/4103) Debugging pipelines no longer requires endless small adjustments and repeated pushes. With the new `woodpecker-cli exec` feature, you can download pipeline metadata directly from the server and replay it locally. This allows you to test and fix issues in a similar environment to the server, all from your machine. ![debug-pipelines-option](debug-pipelines.png) By locally debugging, this feature accelerates the development process and provides deeper insights into pipeline behavior without relying on server-side execution for every small change. :::info In order to use this feature, all required pipeline elements must be passed, e.g. secrets. However, secrets are not included in the pipeline metadata and must be passed manually to the local execution call. ::: ## Rootless images Woodpecker now supports running rootless images by adjusting the entrypoints and directory permissions in the containers in a way that allows non-privileged users to execute tasks. In addition, all images published by Woodpecker (Server, Agent, CLI) now use a non-privileged user (`woodpecker` with UID and GID `1000`) by default. If you have volumes attached to the containers, you may need to change the ownership of these directories from `root` to `woodpecker` by executing `chown -R 1000:1000 `. :::info The agent image must remain rootful by default to be able to mount the Docker socket when Woodpecker is used with the `docker` backend. The helm chart will start to use a non-privileged user by utilizing `securityContext`. Running a completely rootless agent with the `docker` backend may be possible by using a rootless docker daemon. However, this requires more work and is currently not supported. ::: ## Fine grained control over approvals options Woodpecker 3.0.0 introduces enhanced approval options. Beyond requiring approval for all pipeline events, you can now configure it specifically for all pull requests or only for pull requests originating from forks. By default, public repositories will now mandate approval for pull requests from forks. This helps prevent potentially malicious PRs from exposing secrets or performing unauthorized actions without the repository owner's awareness. ![screenshot of new approval-requirements options](approval-requirements.png) ## UI We have fixed many UI-related bugs in this version. Many were small misalignment related to padding, margins or other edge cases related to small screen sizes. We also aimed to harmonize the icons across the UI, specifically across logical subgroups, such as status-icons or admin panel icons. UI elements are now sized in a relative way, meaning they will all scale relative when you change the font-size or zoom in/out. ## Deleting old pipeline logs Deleting a pipeline now successfully also deletes its related logs. Beforehand, there was an issue where the logs were not deleted and were kept in the DB forever. You might want to check [#4572](https://github.com/woodpecker-ci/woodpecker/pull/4572) for more details including a snippet how to delete orphaned entries of a Postgres DB. :::info There is no option yet to auto-delete old pipeline logs after a specific time or event. Please follow [#1068](https://github.com/woodpecker-ci/woodpecker/issues/1068) for future updates. ::: ## Migration to standard Linux CRON syntax CRON definitions now follow standard Linux syntax without seconds. An automatic migration will attempt to update your settings - ensure the update completes successfully. ## Known Issues The generic `pipeline definition not found` is still present and not yet understood. This error message can be triggered by various elements (which the most likely one being a (temporary) connection issue with the forge) and the error return/output must be improved first in order to take appropriate action. ================================================ FILE: docs/docs/10-intro/index.md ================================================ # Welcome to Woodpecker Woodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics. ## Have you ever heard of CI/CD or pipelines? Don't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of checks, tests and routines along the way. A typical pipeline might include the following steps: 1. Running tests 2. Building your application 3. Deploying your application [Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd) ## Do you know containers? If you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/). ## Already have access to a Woodpecker instance? Then you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md). ## Want to start from scratch and deploy your own Woodpecker instance? Woodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance. ================================================ FILE: docs/docs/20-usage/10-intro.md ================================================ # Your first pipeline Let's get started and create your first pipeline. ## 1. Repository Activation To activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click. ![new repository list](repo-new.png) To enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something that is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.). ## 2. Define first workflow After enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository: ```yaml title=".woodpecker/my-first-workflow.yaml" when: - event: push branch: main steps: - name: build image: debian commands: - echo "This is the build step" - echo "binary-data-123" > executable - name: a-test-step image: golang:1.16 commands: - echo "Testing ..." - ./executable ``` **So what did we do here?** 1. We defined your first workflow file `my-first-workflow.yaml`. 2. This workflow will be executed when a push event happens on the `main` branch, because we added a filter using the `when` section: ```diff + when: + - event: push + branch: main ... ``` 3. We defined two steps: `build` and `a-test-step` The steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`. In the `build` step we use the `debian` image and build a "binary file" called `executable`. In the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it. You can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to: ```diff steps: - name: build - image: debian + image: my-company/image-with-aws_cli commands: - aws help ``` ## 3. Push the file and trigger first pipeline If you push this file to your repository now, Woodpecker will already execute your first pipeline. You can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository. ![pipeline view](./pipeline.png) As you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps. This for example allows the first step to build your application using your source code and as the second step will receive the same workspace it can use the previously built binary and test it. ## 4. Use a plugin for reusable tasks Sometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md). If you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline: ```yaml steps: # ... - name: upload image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name access_key: a50d28f4dd477bc184fbd10b376de753 secret_key: from_secret: aws_secret_key source: public/**/* target: /target/location ``` To configure a plugin you can use the `settings` section. Sometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md). Similar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed. Learn more about [plugins](./51-plugins/51-overview.md). As you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md). ================================================ FILE: docs/docs/20-usage/100-troubleshooting.md ================================================ # Troubleshooting ## How to debug clone issues (And what to do with an error message like `fatal: could not read Username for 'https://': No such device or address`) This error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`: ```ini WOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true ``` If that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container "hang": ```yaml skip_clone: true steps: build: image: debian:stable-backports commands: - apt update - apt install -y inetutils-ping wget - ping -c 4 git.example.com - wget git.example.com - sleep 9999999 ``` Get the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline: ```bash git init git remote add origin https://git.example.com/username/repo.git git fetch --no-tags origin +refs/heads/branch: ``` (replace the url AND the branch with the correct values, use your username and password as log in values) ## SELinux Issues When running Woodpecker on systems with SELinux enabled (such as RHEL, CentOS, Fedora, or other Enterprise Linux distributions), SELinux may prevent the agent from accessing the Docker socket. ### Symptoms If SELinux is blocking access, you may see errors like: ```text permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock ``` ### Solutions There are several ways to resolve this: #### Option 1: Set SELinux to Permissive Mode (For Testing Only) Set SELinux to permissive mode temporarily to verify it's the issue: ```bash setenforce 0 ``` To permanently set SELinux to permissive mode: ```bash # Edit /etc/selinux/config SELINUX=permissive ``` #### Option 2: Configure SELinux Policy (Recommended) Create a custom SELinux policy to allow Woodpecker agent to access Docker: ```bash # Generate the policy module ausearch -c 'docker' -avc | audit2allow -R -o woodpecker-docker.te # Build the policy module checkmodule -M -m -o woodpecker-docker.mod woodpecker-docker.te semodule_package -o woodpecker-docker.pp -m woodpecker-docker.mod # Load the policy module semodule -i woodpecker-docker.pp ``` #### Option 3: Use Docker Volume with SELinux Options When using Docker Compose or Docker, add the `:z` or `:Z` option to volume mounts: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock:z ``` The `:z` option tells Docker to automatically relabel the volume content for SELinux. Use `:Z` with caution as it relabels the volume exclusively for this container. #### Option 4: Use Podman (Alternative) If you prefer to avoid SELinux configuration issues, consider using Podman instead of Docker, as it has better SELinux integration. ================================================ FILE: docs/docs/20-usage/15-terminology/architecture.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 226, "versionNonce": 1002880859, "isDeleted": false, "id": "UczUX5VuNnCB1rVvUJVfm", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.098092529257, "y": 320.8758615860986, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 472.8823858375721, "height": 183.19688715994928, "seed": 917720693, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 161, "versionNonce": 286006267, "isDeleted": false, "id": "sKPZmBSWUdAYfBs4ByItH", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 539.5451038202509, "y": 345.2419383247636, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 82.46875, "height": 32.199999999999996, "seed": 1485551573, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Server", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Server", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 333, "versionNonce": 448586907, "isDeleted": false, "id": "_A8uznhnpXuQBYzjP-iVx", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 649.8080506852966, "y": 427.60908869342575, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 136, "height": 60, "seed": 1783625013, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "r90dckf8trHemYzEwCgCW" }, { "id": "XxfJWnHonmvNOJzMFSlie", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 298, "versionNonce": 1244067771, "isDeleted": false, "id": "r90dckf8trHemYzEwCgCW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 703.8080506852966, "y": 441.5090886934257, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 28, "height": 32.199999999999996, "seed": 660965013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113383, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "UI", "textAlign": "center", "verticalAlign": "middle", "containerId": "_A8uznhnpXuQBYzjP-iVx", "originalText": "UI", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 105, "versionNonce": 265992667, "isDeleted": false, "id": "v2eEwSOSRQBZ79O6wyzGf", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 800.9240766836483, "y": 421.4987043996123, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 135.3671503686619, "height": 62.2689029398432, "seed": 1115810805, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "svsVhxCbatcLj7lQLch0P" }, { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 83, "versionNonce": 1706870395, "isDeleted": false, "id": "svsVhxCbatcLj7lQLch0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 828.1594096804793, "y": 436.53315586953386, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 80.896484375, "height": 32.199999999999996, "seed": 2074781013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "GRPC", "textAlign": "center", "verticalAlign": "middle", "containerId": "v2eEwSOSRQBZ79O6wyzGf", "originalText": "GRPC", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 270, "versionNonce": 418660123, "isDeleted": false, "id": "hSrrwwnm9y7R-_CnJtaK1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.567103519039, "y": 556.4146894573112, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 1983197877, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 154, "versionNonce": 871605179, "isDeleted": false, "id": "8tsYgVssKnBd_Zw1QuqNz", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1298.4367898442752, "y": 566.567242947784, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 96.5234375, "height": 32.199999999999996, "seed": 1321669653, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent 1", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent 1", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 182, "versionNonce": 1323136091, "isDeleted": false, "id": "eeugZg73_yD_6uLBBgmcX", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 404.5001910129067, "y": 707.1233710221009, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 210.068359375, "height": 32.199999999999996, "seed": 1901447541, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "User => Browser", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "User => Browser", "lineHeight": 1.15, "baseline": 25 }, { "type": "ellipse", "version": 106, "versionNonce": 1501835515, "isDeleted": false, "id": "mlDhl4OOc-H1tNgh77AAW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 482.5857164810477, "y": 602.4394551739279, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 46.024748503793035, "height": 44.21988070606176, "seed": 791073493, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "line", "version": 166, "versionNonce": 627726747, "isDeleted": false, "id": "ADEXzdYAhvj-_wVRftTIg", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 459.12202200277807, "y": 697.1964604319912, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 80.31792517362464, "height": 31.585599568061298, "seed": 349155381, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": null, "points": [ [ 0, 0 ], [ 42.415150610916044, -28.87829787146393 ], [ 80.31792517362464, 2.7073016965973693 ] ] }, { "type": "rectangle", "version": 231, "versionNonce": 801271355, "isDeleted": false, "id": "xmz4J-rxLIjfUQ4q19PjD", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 516.8788931508789, "y": 870.4664542146543, "strokeColor": "#f08c00", "backgroundColor": "#fff4e6", "width": 385.34512717560705, "height": 60.464035142111264, "seed": 3531157, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 93, "versionNonce": 728690395, "isDeleted": false, "id": "gSbpry_947XArfI7b6AAL", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 636.1468430141358, "y": 878.5884970070326, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 132.2890625, "height": 32.199999999999996, "seed": 1989076725, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Autoscaler", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Autoscaler", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 118, "versionNonce": 1258445691, "isDeleted": false, "id": "WVy0mdTGbUx08RuxdQUH8", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 523.3741602213286, "y": 907.372811672524, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 369.1484375, "height": 18.4, "seed": 979386453, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Starts agents based on amount of pending pipelines", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Starts agents based on amount of pending pipelines", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 373, "versionNonce": 1254044699, "isDeleted": false, "id": "0Y1RcqzVFBFqh-wy-APMI", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1232.1955835481922, "y": 605.8737363119278, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 292.6171875, "height": 18.4, "seed": 561999285, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Executes pending workflows of a pipeline", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Executes pending workflows of a pipeline", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 630, "versionNonce": 983038139, "isDeleted": false, "id": "lGumbhMs3xx1vU2632hli", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 505.62283787078286, "y": 383.42044095379515, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 158.015625, "height": 36.8, "seed": 722595605, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Central unit of a \nWoodpecker instance ", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Central unit of a \nWoodpecker instance ", "lineHeight": 1.15, "baseline": 32 }, { "type": "rectangle", "version": 131, "versionNonce": 137308507, "isDeleted": false, "id": "PbSQXehWVLYcQGXYFpd-B", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 971.7123256059622, "y": 171.06951064323448, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 274.3443117379593, "height": 74.90311522655017, "seed": 1435321461, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 96, "versionNonce": 1222067707, "isDeleted": false, "id": "2P2tz29C_2sUzVNSpaG17", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.5206131439782, "y": 183.12082907329545, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 73.14453125, "height": 32.199999999999996, "seed": 884403669, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Forge", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Forge", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 141, "versionNonce": 1133694619, "isDeleted": false, "id": "0eYhFYPuRanZ7wkR2OlHO", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 986.864582863368, "y": 225.1223531590797, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 247.234375, "height": 18.4, "seed": 1201957685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "HK1jmIcPmM6Us6Jrynobb", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Github, Gitea, Github, Bitbucket, ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Github, Gitea, Github, Bitbucket, ...", "lineHeight": 1.15, "baseline": 14 }, { "type": "rectangle", "version": 55, "versionNonce": 991183675, "isDeleted": false, "id": "dihpRzuIc-UoRSsOI33SZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 820.419424341303, "y": 340.29123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 247151765, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "bcUL-u4zkLA9CLG2YdaeN" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 38, "versionNonce": 2008949723, "isDeleted": false, "id": "bcUL-u4zkLA9CLG2YdaeN", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 831.853994653803, "y": 358.79123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 94.130859375, "height": 23, "seed": 1638982133, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Webhooks", "textAlign": "center", "verticalAlign": "middle", "containerId": "dihpRzuIc-UoRSsOI33SZ", "originalText": "Webhooks", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 93, "versionNonce": 295891067, "isDeleted": false, "id": "Bphhue86mMXHN4klGamM3", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 697.3018309300141, "y": 339.607928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 92986197, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "0YxY2hEPyDWFqR8_-f6bn" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 87, "versionNonce": 2055547163, "isDeleted": false, "id": "0YxY2hEPyDWFqR8_-f6bn", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 727.4522215550141, "y": 358.107928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 56.69921875, "height": 23, "seed": 43952309, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "OAuth", "textAlign": "center", "verticalAlign": "middle", "containerId": "Bphhue86mMXHN4klGamM3", "originalText": "OAuth", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 284, "versionNonce": 1205292475, "isDeleted": false, "id": "HK1jmIcPmM6Us6Jrynobb", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1205.6453201409104, "y": 250.4849674923464, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 272.1094712799886, "height": 94.31865813977868, "seed": 982632981, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "uDIWJ5K5mEBL9QaiNk3cS" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "0eYhFYPuRanZ7wkR2OlHO", "focus": -0.8418551162334328, "gap": 6.962614333266799 }, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ -69.68740859223726, 65.87860410965993 ], [ -272.1094712799886, 94.31865813977868 ] ] }, { "type": "text", "version": 53, "versionNonce": 1803962459, "isDeleted": false, "id": "uDIWJ5K5mEBL9QaiNk3cS", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1050.575099048673, "y": 297.96357160200637, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 170.765625, "height": 36.8, "seed": 1046069109, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "sends events like push, \ntag, ...", "textAlign": "center", "verticalAlign": "middle", "containerId": "HK1jmIcPmM6Us6Jrynobb", "originalText": "sends events like push, tag, ...", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 487, "versionNonce": 335895291, "isDeleted": false, "id": "Kqbwk_qfkALJfhtCIr2eS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 792.0835609101814, "y": 316.38601649373913, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 176.92139414789008, "height": 122.73778943055902, "seed": 1681656021, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yvJTQ64RU50N6-hxEQlkl" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "UczUX5VuNnCB1rVvUJVfm", "focus": -0.03867359238356983, "gap": 4.489845092359474 }, "endBinding": { "elementId": "PbSQXehWVLYcQGXYFpd-B", "focus": 0.7798878042817562, "gap": 2.707370547890605 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 60.422360349016344, -71.97786730696657 ], [ 176.92139414789008, -122.73778943055902 ] ] }, { "type": "text", "version": 62, "versionNonce": 301106427, "isDeleted": false, "id": "yvJTQ64RU50N6-hxEQlkl", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 773.7910775091977, "y": 226.00814918677256, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 157.4296875, "height": 36.8, "seed": 500049461, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "allows users to login \nusing existing account", "textAlign": "center", "verticalAlign": "middle", "containerId": "Kqbwk_qfkALJfhtCIr2eS", "originalText": "allows users to login using existing account", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 393, "versionNonce": 598459861, "isDeleted": false, "id": "TvtonmlV0W8__pnTG-wVZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 936.9267543177084, "y": 458.95033086418084, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 215.17788326846676, "height": 93.99151368376693, "seed": 234198933, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "rFf6NIofw6UBOyAFwg0Kn" } ], "updated": 1697530127259, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.30339107267010673, "gap": 1 }, "endBinding": { "elementId": "hSrrwwnm9y7R-_CnJtaK1", "focus": -0.14057158065513534, "gap": 3.4728449093634026 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 130.0760301643047, 42.90930518030268 ], [ 215.17788326846676, 93.99151368376693 ] ] }, { "type": "text", "version": 8, "versionNonce": 1693330843, "isDeleted": false, "id": "rFf6NIofw6UBOyAFwg0Kn", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 997.4942845557462, "y": 473.9409015069133, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 1592253685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "TvtonmlV0W8__pnTG-wVZ", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 270, "versionNonce": 1855882619, "isDeleted": false, "id": "5tl702dfcvJDLz9aIFU0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 886.0581619083632, "y": 485.67004123832135, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 174.09447592006472, "height": 326.4905563076211, "seed": 1479177813, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "apyMCAv2GIN_yzHXwX4tY" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.1341191028023529, "gap": 1.9024338988657519 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "focus": -0.7088661407505865, "gap": 4.060573862784622 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 44.14165353942735, 196.18483635907205 ], [ 174.09447592006472, 326.4905563076211 ] ] }, { "type": "text", "version": 66, "versionNonce": 2007745083, "isDeleted": false, "id": "apyMCAv2GIN_yzHXwX4tY", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 849.4927841977906, "y": 663.4548775973934, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 882041781, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "5tl702dfcvJDLz9aIFU0P", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 347, "versionNonce": 1353818811, "isDeleted": false, "id": "XxfJWnHonmvNOJzMFSlie", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 534.9278465333664, "y": 595.2199151317081, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 113.88020415193023, "height": 119.81968366814112, "seed": 944153877, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "_A8uznhnpXuQBYzjP-iVx", "focus": 0.5397285671082249, "gap": 1 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 113.88020415193023, -119.81968366814112 ] ] }, { "type": "rectangle", "version": 61, "versionNonce": 1099141979, "isDeleted": false, "id": "j56ZKRwmXk72nHrZzLz_1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1081.8110514012087, "y": 652.5253283508498, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 566.7373014532342, "height": 68.58600908319681, "seed": 112933493, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 82, "versionNonce": 1879994363, "isDeleted": false, "id": "cAVYXfBRnfuGAv7QTQVow", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1300.6584159706863, "y": 658.8425033454967, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 77.83203125, "height": 23, "seed": 951460821, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Backend", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Backend", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 376,- add some images explaining the architecture & terminology with pipeline -> workflow -> step - combine advanced config usage - rename pipeline syntax to workflow syntax (and most references to pipeline steps etc as well) - update agent registration part - add bug note to secrets encryption setting - remove usage from readme to point to up-to-date docs page - typos - closes #1408 --------- "angle": 0, "x": 1094.1972977313717, "y": 681.8988272758752, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 530.9453125, "height": 55.199999999999996, "seed": 843899189, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "lineHeight": 1.15, "baseline": 50 }, { "type": "rectangle", "version": 384, "versionNonce": 1778969915, "isDeleted": false, "id": "pxF49EKDNO6IZq_34i7bY", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1064.2132116912126, "y": 754.5018564383092, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 954528405, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "arrow", "version": 154, "versionNonce": 1988988379, "isDeleted": false, "id": "05EJzh4NLXxemaKAmdi5n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 904.0288881242177, "y": 882.4966027880746, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 158.83070714434325, "height": 32.735025983189644, "seed": 1228134389, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yNxAOEPZu_Jl7mnI01OXs" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "xmz4J-rxLIjfUQ4q19PjD", "gap": 1.8048677977312764, "focus": 0.31250963573550006 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "gap": 1.353616422651612, "focus": 0.36496042109885213 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 158.83070714434325, -32.735025983189644 ] ] }, { "type": "text", "version": 25, "versionNonce": 1393410779, "isDeleted": false, "id": "yNxAOEPZu_Jl7mnI01OXs", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 963.8856479463893, "y": 856.9290897964797, "strokeColor": "#f08c00", "backgroundColor": "#b2f2bb", "width": 39.1171875, "height": 18.4, "seed": 759107925, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113387, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "starts", "textAlign": "center", "verticalAlign": "middle", "containerId": "05EJzh4NLXxemaKAmdi5n", "originalText": "starts", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 187, "versionNonce": 671224603, "isDeleted": false, "id": "sSj4Pda-fo-BBYM_dzml6", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1296.0854928322988, "y": 776.6118140041631, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 104.2890625, "height": 32.199999999999996, "seed": 1381768885, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent ...", "lineHeight": 1.15, "baseline": 25 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/docs/20-usage/15-terminology/index.md ================================================ # Terminology ## Glossary - **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC. - **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines. - **Code**: Refers to the files tracked by the version control system used by the [forge][Forge]. - **Commit**: A defined state of the code, usually associated with a version control system like Git. - **Container**: A lightweight and isolated environment where commands are executed. - **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel. - **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI. - **[Extension][Extension]**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through extensions. - **[Forge][Forge]**: The hosting platform or service where the repositories are hosted. - **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix. - **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events. - **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings. - **Repos**: Short for repositories, these are storage locations where code is stored. - **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration. - **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow]. - **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge]. - **Steps**: Individual commands, actions or tasks within a [workflow][Workflow]. - **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue. - **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code. - **Woodpecker CI**: The project name around Woodpecker. - **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker). - **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps. - **YAML File**: A file format used to define and configure [workflows][Workflow]. ## Woodpecker architecture ![Woodpecker architecture](architecture.svg) ## Pipeline, workflow & step ![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg) ## Conventions Sometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker: - Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()` - Use the term **pipelines** instead of the previous **builds** - Use the term **steps** instead of the previous **jobs** - Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users [Event]: ../20-workflow-syntax.md#event [Pipeline]: ../20-workflow-syntax.md [Workflow]: ../25-workflows.md [Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md [Plugin]: ../51-plugins/51-overview.md [Workspace]: ../20-workflow-syntax.md#workspace [Matrix]: ../30-matrix-workflows.md [Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md [Local]: ../../30-administration/10-configuration/11-backends/30-local.md [Extension]: ../72-extensions/index.md ================================================ FILE: docs/docs/20-usage/15-terminology/pipeline-workflow-step.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 97, "versionNonce": 257762037, "isDeleted": false, "id": "Y3hYdpX9r1qWfyHWs7AXT", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 393.622323134362, "y": 336.02197155458475, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 366.3936710429598, "height": 499.95605689083004, "seed": 875444373, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 67, "versionNonce": 369556565, "isDeleted": false, "id": "g1Eb010Kx_KFryVqNYWBQ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 520.0116988873679, "y": 363.32095846456355, "strokeColor": "#1971c2", "backgroundColor": "#b2f2bb", "width": 99.626953125, "height": 32.199999999999996, "seed": 1466195445, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Pipeline", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Pipeline", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 314, "versionNonce": 1983028731, "isDeleted": false, "id": "9o-DNP0YdlIGVz1kEm_hW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 407.1590381712276, "y": 410.9252244837219, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1869535061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" }, { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083624, "link": null, "locked": false }, { "type": "rectangle", "version": 156, "versionNonce": 1495247317, "isDeleted": false, "id": "q4TKpiq2KAwPaz19GdhtK", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 490.3194993196821, "y": 473.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 111355061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "ya0JzDo-4oscHIq87TZ_D" }, { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" }, { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 156, "versionNonce": 1469425461, "isDeleted": false, "id": "ya0JzDo-4oscHIq87TZ_D", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 566.0118821321821, "y": 478.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1084671509, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "q4TKpiq2KAwPaz19GdhtK", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 236, "versionNonce": 1535319541, "isDeleted": false, "id": "AOJLQFldoHd2vxVtB2jrS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 491.2218643672577, "y": 519.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 812596085, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "FRby8A9aUiKvHpM5mCdDN" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 231, "versionNonce": 28677973, "isDeleted": false, "id": "FRby8A9aUiKvHpM5mCdDN", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 583.0324112422577, "y": 524.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1849820373, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "AOJLQFldoHd2vxVtB2jrS", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 291, "versionNonce": 571598005, "isDeleted": false, "id": "2WwuMWX7YawqK0i1rDPJo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 489.6426911083554, "y": 567.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1840554549, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "UOwxmKIS0W62CFt_ffEy4" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 289, "versionNonce": 4032021, "isDeleted": false, "id": "UOwxmKIS0W62CFt_ffEy4", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 581.4532379833554, "y": 572.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 330077077, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "2WwuMWX7YawqK0i1rDPJo", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 296, "versionNonce": 1539516059, "isDeleted": false, "id": "9laL3864YWOna6NQlVDqq", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 630.0635849044402, "y": 383.14314287821776, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 294.3024370154917, "height": 36.656016722015465, "seed": 207575285, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -1.000156025347643, "gap": 27.782081605504118 }, "endBinding": { "elementId": "vS2PNUbmeBe3EPxl-dID8", "focus": 0.7761987167055517, "gap": 8.978940924346716 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 294.3024370154917, -36.656016722015465 ] ] }, { "type": "text", "version": 249, "versionNonce": 2076402229, "isDeleted": false, "id": "vS2PNUbmeBe3EPxl-dID8", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 933.3449628442786, "y": 336.02200598023114, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 301.298828125, "height": 46, "seed": 1632793173, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "A pipeline is triggered by an event\nlike a push, tag, manual", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "A pipeline is triggered by an event\nlike a push, tag, manual", "lineHeight": 1.15, "baseline": 41 }, { "type": "arrow", "version": 751, "versionNonce": 1371044827, "isDeleted": false, "id": "FU4jk6Tz6duLaaZE0Z55A", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 751.1619011845514, "y": 440.8355079324799, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 160.46519124360202, "height": 2.2452348338335923, "seed": 1331388341, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -0.6591700594229558, "gap": 3.8807513696519322 }, "endBinding": { "elementId": "wfFvnFZuh0npL9hh0ez7o", "focus": 0.7652411053273549, "gap": 20.75618622779257 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 160.46519124360202, -2.2452348338335923 ] ] }, { "type": "rectangle", "version": 440, "versionNonce": 819540565, "isDeleted": false, "id": "TbejdIYo_qNDw15yLP2IB", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 406.0812257713851, "y": 626.8305540252475, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1553965333, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 466, "versionNonce": 663477, "isDeleted": false, "id": "wfFvnFZuh0npL9hh0ez7o", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 932.383278655946, "y": 424.0107569968011, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 481.2890625, "height": 115, "seed": 781497973, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "lineHeight": 1.15, "baseline": 110 }, { "type": "arrow", "version": 464, "versionNonce": 734626075, "isDeleted": false, "id": "1ZbDRqbETCkEx62nCmnpJ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 741.0645380446722, "y": 492.31283255558515, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 178.4459423531871, "height": 83.08707392565111, "seed": 536879061, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "q4TKpiq2KAwPaz19GdhtK", "focus": -0.7697471991854113, "gap": 3.7450387249900814 }, "endBinding": { "elementId": "Vu0JJ6ZWuEhEyCfxeHPtc", "focus": -0.7822252364700005, "gap": 8.360835317635974 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 178.4459423531871, 83.08707392565111 ] ] }, { "type": "text", "version": 327, "versionNonce": 371646421, "isDeleted": false, "id": "Vu0JJ6ZWuEhEyCfxeHPtc", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 927.8713157154953, "y": 563.2132686484658, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 491.357421875, "height": 46, "seed": 385310005, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "lineHeight": 1.15, "baseline": 41 }, { "type": "text", "version": 91, "versionNonce": 1180085909, "isDeleted": false, "id": "0tGx2VdJLNf7W6HD76dtO", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 427.6895298601876, "y": 432.3583566254258, "strokeColor": "#9c36b5", "backgroundColor": "#a5d8ff", "width": 143.876953125, "height": 23, "seed": 450883221, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"build\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"build\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 338, "versionNonce": 957223925, "isDeleted": false, "id": "LQ2h2aO9uzDWyLG6OLn70", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.7251825950889, "y": 685.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 711939061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "8EqaPnZX2CgLaF08UNZZg" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 340, "versionNonce": 510774613, "isDeleted": false, "id": "8EqaPnZX2CgLaF08UNZZg", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 563.4175654075889, "y": 690.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1370164565, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "LQ2h2aO9uzDWyLG6OLn70", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 421, "versionNonce": 97999541, "isDeleted": false, "id": "St9t4nwHuXXVlmjDqfn_Z", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 488.62754764266447, "y": 731.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 2145950389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "DX10t075MMDu7BLtuUaij" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 417, "versionNonce": 2011446293, "isDeleted": false, "id": "DX10t075MMDu7BLtuUaij", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 580.4380945176645, "y": 736.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 500005909, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "St9t4nwHuXXVlmjDqfn_Z", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 475, "versionNonce": 1284370805, "isDeleted": false, "id": "XVGBz_X5yN6xjWTosVH2n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.04837438376217, "y": 779.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1666134389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "-xogFSFcP-Vv5cuOSFm8T" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 476, "versionNonce": 1092221653, "isDeleted": false, "id": "-xogFSFcP-Vv5cuOSFm8T", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 578.8589212587622, "y": 784.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1840462549, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "XVGBz_X5yN6xjWTosVH2n", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 125, "versionNonce": 1310578741, "isDeleted": false, "id": "N1a9yL7Pts16hUKY9-vhw", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 424.78852030984035, "y": 646.2446482189896, "strokeColor": "#be4bdb", "backgroundColor": "#a5d8ff", "width": 133.857421875, "height": 23, "seed": 361699381, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"test\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"test\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 184, "versionNonce": 2127603131, "isDeleted": false, "id": "O-YmtRLb8uFNqCAz22EoG", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 737.454940151797, "y": 535.9141784615474, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 190.41665096887027, "height": 112.96427727851824, "seed": 80234901, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.8392895251910331, "gap": 2.0300115262207328 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 190.41665096887027, 112.96427727851824 ] ] }, { "type": "arrow", "version": 327, "versionNonce": 780710651, "isDeleted": false, "id": "379hO6Dc5rygB38JgDbVo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 738.8084877231549, "y": 591.3526691276127, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 186.8066399682357, "height": 57.68023784868956, "seed": 211046133, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "2WwuMWX7YawqK0i1rDPJo", "focus": -0.5776522830934517, "gap": 2.1657966147995467 }, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.7269489945238884, "gap": 4.286474955497397 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 186.8066399682357, 57.68023784868956 ] ] }, { "type": "text", "version": 285, "versionNonce": 1165977685, "isDeleted": false, "id": "0TjxOfERekC91N3yciQIq", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 929.901602646888, "y": 632.4760859429873, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 518.076171875, "height": 46, "seed": 997763157, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "O-YmtRLb8uFNqCAz22EoG", "type": "arrow" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "lineHeight": 1.15, "baseline": 41 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/docs/20-usage/20-workflow-syntax.md ================================================ # Workflow syntax The Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status. :::note An exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run. ::: :::note We support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility. Read more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3) ::: Example steps: ```yaml steps: - name: backend image: golang commands: - go build - go test - name: frontend image: node commands: - npm install - npm run test - npm run build ``` In the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary. The name is optional, if not added the steps will be numerated. Another way to name a step is by using dictionaries: ```yaml steps: backend: image: golang commands: - go build - go test frontend: image: node commands: - npm install - npm run test - npm run build ``` ## Skip Commits Woodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive. ```bash git commit -m "updated README [CI SKIP]" ``` ## Steps Every step of your workflow executes commands inside a specified container.
The defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).
The associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` ### File changes are incremental - Woodpecker clones the source code in the beginning of the workflow - Changes to files are persisted through steps as the same volume is mounted to all steps ```yaml title=".woodpecker.yaml" steps: - name: build image: debian commands: - echo "test content" > myfile - name: a-test-step image: debian commands: - cat myfile ``` ### `image` Woodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers. When using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands. ```diff steps: - name: build + image: golang:1.6 commands: - go build - go test - name: prettier + image: woodpeckerci/plugin-prettier services: - name: database + image: mysql ``` Woodpecker supports any valid Docker image from any Docker registry: ```yaml image: golang image: golang:1.7 image: library/golang:1.7 image: index.docker.io/library/golang image: index.docker.io/library/golang:1.7 ``` Learn more how you can use images from [different registries](./41-registries.md). ### `pull` By default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present. To always pull the latest image when updates are available, use the `pull` option: ```diff steps: - name: build image: golang:latest + pull: true ``` ### `commands` Commands of every step are executed serially as if you would enter them into your local shell. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` There is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script: ```bash #!/bin/sh set -e go build go test ``` The above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed: ```bash docker run --entrypoint=build.sh golang ``` :::note Only build steps can define commands. You cannot use commands with plugins or services. ::: ### `entrypoint` Allows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `["/bin/sh", "-c"]`). If you define [`commands`](#commands), the default entrypoint will be `["/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"]`. You can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`. ### `environment` Woodpecker provides the ability to pass environment variables to individual steps. For more details, check the [environment docs](./50-environment.md). ### `failure` Some of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow. ```diff steps: - name: backend image: golang commands: - go build - go test + failure: ignore ``` If you would like to cancel the full pipeline once the step fails, you can set `failure: cancel`. ### `when` - Conditional Execution Woodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true. A condition can be a check like: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - event: pull_request + repo: test/test + - event: push + branch: main ``` The `prettier` step is executed if one of these conditions is met: 1. The pipeline is executed from a pull request in the repo `test/test` 2. The pipeline is executed from a push to `main` #### `repo` Example conditional execution by repository: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - repo: test/test ``` #### `branch` :::note Branch conditions are not applied to tags. ::: Example conditional execution by branch: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - branch: main ``` > The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only. Execute a step if the branch is `main` or `develop`: ```yaml when: - branch: [main, develop] ``` Execute a step if the branch starts with `prefix/*`: ```yaml when: - branch: prefix/* ``` The branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples: - `*\\/*` to match patterns with exactly 1 `/` - `*\\/**` to match patters with at least 1 `/` - `*` to match patterns without `/` - `**` to match everything Execute a step using custom include and exclude logic: ```yaml when: - branch: include: [main, release/*] exclude: [release/1.0.0, release/1.1.*] ``` #### `event` The available events are: - `push`: triggered when a commit is pushed to a branch. - `pull_request`: triggered when a pull request is opened or a new commit is pushed to it. - `pull_request_closed`: triggered when a pull request is closed or merged. - `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...). - `tag`: triggered when a tag is pushed. - `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).) - `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.) - `cron`: triggered when a cron job is executed. - `manual`: triggered when a user manually triggers a pipeline. Execute a step if the build event is a `tag`: ```yaml when: - event: tag ``` Execute a step if the pipeline event is a `push` to a specified branch: ```diff when: - event: push + branch: main ``` Execute a step for multiple events: ```yaml when: - event: [push, tag, deployment] ``` #### `cron` This filter **only** applies to cron events and filters based on the name of a cron job. Make sure to have a `event: cron` condition in the `when`-filters as well. ```yaml when: - event: cron cron: sync_* # name of your cron job ``` [Read more about cron](./45-cron.md) #### `ref` The `ref` filter compares the git reference against which the workflow is executed. This allows you to filter, for example, tags that must start with **v**: ```yaml when: - event: tag ref: refs/tags/v* ``` #### `status` By default, steps only run when the workflow has succeeded up to that point,
which is equivalent to `status: [ success ]`. The `status` filter lets you override this behavior. The only accepted values are `success` and `failure`. A common use case is executing a step on failure, such as sending notifications for a failed workflow/pipeline. To run a step regardless of outcome, list both values: ```diff steps: - name: notify image: alpine + when: + - status: [ success, failure ] ``` The filter is aware of the other filters. If you want to run on failures if the event is `tag`, but if it's a `pull_request`, run it on both success and failure: ```diff when: + - event: tag + status: [ failure ] + - event: pull_request + status: [ success, failure ] ``` If there's no matching filter at all or all matching filters don't have set `status`, it will use the default, which means it runs on success only. In the example above this will happen if the event is neither `tag` nor `pull_request`. #### `platform` :::note This condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch. ::: Execute a step for a specific platform: ```yaml when: - platform: linux/amd64 ``` Execute a step for a specific platform using wildcards: ```yaml when: - platform: [linux/*, windows/amd64] ``` #### `matrix` Execute a step for a single matrix permutation: ```yaml when: - matrix: GO_VERSION: 1.5 REDIS_VERSION: 2.8 ``` #### `instance` Execute a step only on a certain Woodpecker instance matching the specified hostname: ```yaml when: - instance: stage.woodpecker.company.com ``` #### `path` :::info Path conditions are applied only to **push** and **pull_request** events. ::: Execute a step only on a pipeline with certain files being changed: ```yaml when: - path: 'src/*' ``` You can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`. For pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases. ```yaml when: - path: include: ['.woodpecker/*.yaml', '*.ini'] exclude: ['*.md', 'docs/**'] ignore_message: '[ALL]' on_empty: true ``` :::info Passing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting. ::: #### `evaluate` Execute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression. The expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library. Run on pushes to the default branch for the repository `owner/repo`: ```yaml when: - evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' ``` Run on commits created by user `woodpecker-ci`: ```yaml when: - evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' ``` Skip all commits containing `please ignore me` in the commit message: ```yaml when: - evaluate: 'not (CI_COMMIT_MESSAGE contains "please ignore me")' ``` Run on pull requests with the label `deploy`: ```yaml when: - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "deploy"' ``` Skip step only if `SKIP=true`, run otherwise or if undefined: ```yaml when: - evaluate: 'SKIP != "true"' ``` ### `depends_on` Normally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`: ```diff steps: - name: build # build will be executed immediately image: golang commands: - go build - name: deploy image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file + depends_on: [build, test] # deploy will be executed after build and test finished - name: test # test will be executed immediately as no dependencies are set image: golang commands: - go test ``` :::note You can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified. ```yaml steps: - name: check code format image: mstruebing/editorconfig-checker depends_on: [] # enable parallel steps ... ``` ::: ### `volumes` Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. For more details check the [volumes docs](./70-volumes.md). ### `detach` Woodpecker gives the ability to detach steps to run them in background until the workflow finishes. For more details check the [service docs](./60-services.md#detachment). ### `directory` Using `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run. ### `backend_options` With `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes. Further details can be found in the documentation of the used backend: - [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration) - [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration) ## `services` Woodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow. For more details check the [services docs](./60-services.md). ## `workspace` The workspace defines the shared volume and working directory shared by all workflow steps. The default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`). So an example would be `/woodpecker/src/github.com/octocat/hello-world`. The workspace can be customized using the workspace block in the YAML file: ```diff +workspace: + base: /go + path: src/github.com/octocat/hello-world steps: - name: build image: golang:latest commands: - go get - go test ``` :::note Plugins will always have the workspace base at `/woodpecker` ::: The base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps. ```diff workspace: + base: /go path: src/github.com/octocat/hello-world steps: - name: deps image: golang:latest commands: - go get - go test - name: build image: node:latest commands: - go build ``` This would be equivalent to the following docker commands: ```bash docker volume create my-named-volume docker run --volume=my-named-volume:/go golang:latest docker run --volume=my-named-volume:/go node:latest ``` The path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path. ```diff workspace: base: /go + path: src/github.com/octocat/hello-world ``` ```bash git clone https://github.com/octocat/hello-world \ /go/src/github.com/octocat/hello-world ``` ## `matrix` Woodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations. For more details check the [matrix build docs](./30-matrix-workflows.md). ## `labels` You can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent. To specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo. Workflow labels with an empty value are ignored. By default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`. :::warning Labels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition. ::: You can add additional labels as a key value map: ```diff +labels: + location: europe # only agents with `location=europe` or `location=*` will be used + weather: sun + hostname: "" # this label will be ignored as it is empty steps: - name: build image: golang commands: - go build - go test ``` ### Filter by platform To configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key. Have a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`. Example: Assuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`. ```diff +labels: + platform: linux/arm64 steps: [...] ``` ## `variables` Woodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration. For more details and examples check the [Advanced usage docs](./90-advanced-usage.md) ## `clone` Woodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step. You can manually configure the clone step in your workflow to customize it: ```diff +clone: + git: + image: woodpeckerci/plugin-git steps: - name: build image: golang commands: - go build - go test ``` Example configuration to override the depth: ```diff clone: - name: git image: woodpeckerci/plugin-git + settings: + partial: false + depth: 50 ``` Example configuration to use a custom clone plugin: ```diff clone: - name: git + image: octocat/custom-git-plugin ``` ### Git Submodules To use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`: ```diff [submodule "my-module"] path = my-module -url = git@github.com:octocat/my-module.git +url = https://github.com/octocat/my-module.git ``` To use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`: ```diff clone: - name: git image: woodpeckerci/plugin-git settings: recursive: true + submodule_override: + my-module: https://github.com/octocat/my-module.git steps: ... ``` ## `skip_clone` :::warning The default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`. ::: By default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using: ```yaml skip_clone: true ``` ## `when` - Global workflow conditions Woodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue. For more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution). Example conditional execution by branch: ```diff +when: + branch: main + steps: - name: prettier image: woodpeckerci/plugin-prettier ``` The workflow now triggers on `main`, but also if the target branch of a pull request is `main`. ## `depends_on` Woodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword. ## Advanced network options for steps :::warning Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. ::: ### `dns` If the backend engine understands to change the DNS server and lookup domain, this options will be used to alter the default DNS config to a custom one for a specific step. ```yaml steps: - name: build image: plugin/abc dns: 1.2.3.4 dns_search: 'internal.company' ``` ## Privileged mode Woodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities. :::info Privileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker environment: - DOCKER_HOST=tcp://docker:2375 commands: - docker --tls=false ps services: - name: docker image: docker:dind commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false + privileged: true ``` ================================================ FILE: docs/docs/20-usage/25-workflows.md ================================================ # Workflows A pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps. In case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow. By placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored. You can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md). ## Benefits of using workflows - faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote - better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying - utilizing more agents to speed up the execution of the whole pipeline ## Example workflow definition :::warning Please note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow. If you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket). ::: ```bash .woodpecker/ ├── build.yaml ├── deploy.yaml ├── lint.yaml └── test.yaml ``` ```yaml title=".woodpecker/build.yaml" steps: - name: build image: debian:stable-slim commands: - echo building - sleep 5 ``` ```yaml title=".woodpecker/deploy.yaml" steps: - name: deploy image: debian:stable-slim commands: - echo deploying depends_on: - lint - build - test ``` ```yaml title=".woodpecker/test.yaml" steps: - name: test image: debian:stable-slim commands: - echo testing - sleep 5 depends_on: - build ``` ```yaml title=".woodpecker/lint.yaml" steps: - name: lint image: debian:stable-slim commands: - echo linting - sleep 5 ``` ## Status lines Each workflow will report its own status back to your forge. ## Flow control The workflows run in parallel on separate agents and share nothing. Dependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully. The name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`. ```diff steps: - name: deploy image: debian:stable-slim commands: - echo deploying +depends_on: + - lint + - build + - test ``` Workflows that need to run even on failures should set the `status` filter. ```diff steps: - name: notify image: debian:stable-slim commands: - echo notifying depends_on: - deploy +when: + - status: [ success, failure ] ``` This works just like the [`status` filter for steps](./20-workflow-syntax.md#status). :::info Some workflows don't need the source code, like creating a notification on failure. Read more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone) ::: ================================================ FILE: docs/docs/20-usage/30-matrix-workflows.md ================================================ # Matrix workflows Woodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations. :::warning Woodpecker currently supports a maximum of **27 matrix axes** per workflow. If your matrix exceeds this number, any additional axes will be silently ignored. ::: Example matrix definition: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 REDIS_VERSION: - 2.6 - 2.8 - 3.0 ``` Example matrix definition containing only specific combinations: ```yaml matrix: include: - GO_VERSION: 1.4 REDIS_VERSION: 2.8 - GO_VERSION: 1.5 REDIS_VERSION: 2.8 - GO_VERSION: 1.6 REDIS_VERSION: 3.0 ``` ## Interpolation Matrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 DATABASE: - mysql:8 - mysql:5 - mariadb:10.1 steps: - name: build image: golang:${GO_VERSION} commands: - go get - go build - go test services: - name: database image: ${DATABASE} ``` Example YAML file after injecting the matrix parameters: ```diff steps: - name: build - image: golang:${GO_VERSION} + image: golang:1.4 commands: - go get - go build - go test + environment: + - GO_VERSION=1.4 + - DATABASE=mysql:8 services: - name: database - image: ${DATABASE} + image: mysql:8 ``` ## Examples ### Example matrix pipeline based on Docker image tag ```yaml matrix: TAG: - 1.7 - 1.8 - latest steps: - name: build image: golang:${TAG} commands: - go build - go test ``` ### Example matrix pipeline based on container image ```yaml matrix: IMAGE: - golang:1.7 - golang:1.8 - golang:latest steps: - name: build image: ${IMAGE} commands: - go build - go test ``` ### Example matrix pipeline using multiple platforms ```yaml matrix: platform: - linux/amd64 - linux/arm64 labels: platform: ${platform} steps: - name: test image: alpine commands: - echo "I am running on ${platform}" - name: test-arm-only image: alpine commands: - echo "I am running on ${platform}" - echo "Arm is cool!" when: platform: linux/arm* ``` :::note If you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector). ::: ================================================ FILE: docs/docs/20-usage/40-secrets.md ================================================ # Secrets Woodpecker provides the ability to store named variables in a central secret store. These secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`. There are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins): 1. **Repository secrets**: Available for all pipelines of a repository. 1. **Organization secrets**: Available for all pipelines of an organization. 1. **Global secrets**: Can only be set by instance administrators. Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution. In addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources. :::warning Woodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs. ::: ## Usage You can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax. The following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`: ```diff steps: - name: 'step name' image: registry/repo/image:tag commands: + - echo "The secret is $TOKEN_ENV" + environment: + TOKEN_ENV: + from_secret: secret_token ``` The same syntax can be used to pass secrets to (plugin) settings. A secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details). `PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution. ```diff steps: - name: 'step name' image: registry/repo/image:tag + settings: + TOKEN: + from_secret: secret_token ``` ### Escape secrets Please note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts. If secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing. ```diff steps: - name: docker image: docker commands: - - echo ${TOKEN_ENV} + - echo $${TOKEN_ENV} environment: TOKEN_ENV: from_secret: secret_token ``` ### Events filter By default, secrets are not exposed to pull requests. However, you can change this behavior by creating the secret and enabling the `pull_request` event type. This can be configured either via the UI or via the CLI. :::warning Be careful when exposing secrets for pull requests. If your repository is public and accepts pull requests from everyone, your secrets may be at risk. Malicious actors could take advantage of this to expose your secrets or transfer them to an external location. ::: ### Plugins filter To prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins. If enabled, they are not available to any other plugins. Plugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets. :::tip If you specify a tag, the filter will take it into account. However, if the same image appears several times in the list, the least privileged entry will take precedence. For example, an image without a tag will allow all tags, even if it contains another entry with a tag attached. ::: ![plugins filter](./secrets-plugins-filter.png) ## CLI In addition to the UI, secrets can also be managed using the CLI. Create the secret with the default settings. The secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events). ```bash woodpecker-cli repo secret add \ --repository octocat/hello-world \ --name aws_access_key_id \ --value ``` Create the secret and limit it to a single image: ```diff woodpecker-cli secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ --name aws_access_key_id \ --value ``` Create the secrets and limit it to a set of images: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ + --image woodpeckerci/plugin-docker-buildx \ --name aws_access_key_id \ --value ``` Create the secret and enable it for multiple hook events: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ --image woodpeckerci/plugin-s3 \ + --event pull_request \ + --event push \ + --event tag \ --name aws_access_key_id \ --value ``` Secrets can be loaded from a file using the syntax `@`. This method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example): ```diff woodpecker-cli repo secret add \ -repository octocat/hello-world \ -name ssh_key \ + -value @/root/ssh/id_rsa ``` ================================================ FILE: docs/docs/20-usage/41-registries.md ================================================ # Registries Woodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries. ## Images from private registries You must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file. These credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin. Example configuration using a private image: ```diff steps: - name: build + image: gcr.io/custom/golang commands: - go build - go test ``` Woodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers. Example registry hostnames: - Image `gcr.io/foo/bar` has hostname `gcr.io` - Image `foo/bar` has hostname `docker.io` - Image `qux.com:8000/foo/bar` has hostname `qux.com:8000` Example registry hostname matching logic: - Hostname `gcr.io` matches image `gcr.io/foo/bar` - Hostname `docker.io` matches `golang` - Hostname `docker.io` matches `library/golang` - Hostname `docker.io` matches `bradrydzewski/golang` - Hostname `docker.io` matches `bradrydzewski/golang:latest` ## Global registry support To make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config). ## GCR registry support For specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file). ## Local Images :::warning For this, privileged rights are needed only available to admins. In addition, this only works when using a single agent. ::: It's possible to build a local image by mounting the docker socket as a volume. With a `Dockerfile` at the root of the project: ```yaml steps: - name: build-image image: docker commands: - docker build --rm -t local/project-image . volumes: - /var/run/docker.sock:/var/run/docker.sock - name: build-project image: local/project-image commands: - ./build.sh ``` ================================================ FILE: docs/docs/20-usage/45-cron.md ================================================ # Cron To configure cron jobs you need at least push access to the repository. ## Add a new cron job 1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job: ```diff steps: - name: sync_locales image: weblate_sync settings: url: example.com token: from_secret: weblate_token + when: + event: cron + cron: "name of the cron job" # if you only want to execute this step by a specific cron job ``` 2. Create a new cron job in the repository settings: ![cron settings](./cron-settings.png) The supported schedule syntax can be found at . If you need general understanding of the cron syntax is a good place to start and experiment. Examples: `@every 5m`, `@daily`, `30 * * * *` ... ================================================ FILE: docs/docs/20-usage/50-environment.md ================================================ # Environment variables Woodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables: ```diff steps: - name: build image: golang + environment: + CGO: 0 + GOOS: linux + GOARCH: amd64 commands: - go build - go test ``` Please note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section. ```diff steps: - name: build image: golang - environment: - - PATH=$PATH:/go commands: + - export PATH=$PATH:/go - go build - go test ``` :::warning `${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped: ::: ```diff steps: - name: build image: golang commands: - - export PATH=${PATH}:/go + - export PATH=$${PATH}:/go - go build - go test ``` ## Built-in environment variables This is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime. | NAME | Description | Example | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `CI` | CI environment name | `woodpecker` | | | **Repository** | | | `CI_REPO` | repository full name `/` | `john-doe/my-repo` | | `CI_REPO_OWNER` | repository owner | `john-doe` | | `CI_REPO_NAME` | repository name | `my-repo` | | `CI_REPO_REMOTE_ID` | repository remote ID, is the UID it has in the forge | `82` | | `CI_REPO_URL` | repository web URL | `https://git.example.com/john-doe/my-repo` | | `CI_REPO_CLONE_URL` | repository clone URL | `https://git.example.com/john-doe/my-repo.git` | | `CI_REPO_CLONE_SSH_URL` | repository SSH clone URL | `git@git.example.com:john-doe/my-repo.git` | | `CI_REPO_DEFAULT_BRANCH` | repository default branch | `main` | | `CI_REPO_PRIVATE` | repository is private | `true` | | `CI_REPO_TRUSTED_NETWORK` | repository has trusted network access | `false` | | `CI_REPO_TRUSTED_VOLUMES` | repository has trusted volumes access | `false` | | `CI_REPO_TRUSTED_SECURITY` | repository has trusted security access | `false` | | | **Current Commit** | | | `CI_COMMIT_SHA` | commit SHA | `eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_COMMIT_REF` | commit ref | `refs/heads/main` | | `CI_COMMIT_REFSPEC` | commit ref spec | `issue-branch:main` | | `CI_COMMIT_BRANCH` | commit branch (equals target branch for pull requests) | `main` | | `CI_COMMIT_SOURCE_BRANCH` | commit source branch (set only for pull request events) | `issue-branch` | | `CI_COMMIT_TARGET_BRANCH` | commit target branch (set only for pull request events) | `main` | | `CI_COMMIT_TAG` | commit tag name (empty if event is not `tag`) | `v1.10.3` | | `CI_COMMIT_PULL_REQUEST` | commit pull request number (set only for pull request events) | `1` | | `CI_COMMIT_PULL_REQUEST_LABELS` | labels assigned to pull request (set only for pull request events) | `server` | | `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events) | `summer-sprint` | | `CI_COMMIT_MESSAGE` | commit message | `Initial commit` | | `CI_COMMIT_AUTHOR` | commit author username | `john-doe` | | `CI_COMMIT_AUTHOR_EMAIL` | commit author email address | `john-doe@example.com` | | `CI_COMMIT_PRERELEASE` | release is a pre-release (empty if event is not `release`) | `false` | | | **Current pipeline** | | | `CI_PIPELINE_NUMBER` | pipeline number | `8` | | `CI_PIPELINE_PARENT` | number of parent pipeline | `0` | | `CI_PIPELINE_STATUS` | state of the workflow right before the step was started | `success`, `failure` | | `CI_PIPELINE_EVENT` | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PIPELINE_EVENT_REASON` | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PIPELINE_URL` | link to the web UI for the pipeline | `https://ci.example.com/repos/7/pipeline/8` | | `CI_PIPELINE_FORGE_URL` | link to the forge's web UI for the commit(s) or tag that triggered the pipeline | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_PIPELINE_DEPLOY_TARGET` | pipeline deploy target for `deployment` events | `production` | | `CI_PIPELINE_DEPLOY_TASK` | pipeline deploy task for `deployment` events | `migration` | | `CI_PIPELINE_CREATED` | pipeline created UNIX timestamp | `1722617519` | | `CI_PIPELINE_STARTED` | pipeline started UNIX timestamp | `1722617519` | | `CI_PIPELINE_FILES` | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[".woodpecker.yml","README.md"]` | | `CI_PIPELINE_AUTHOR` | pipeline author username | `octocat` | | `CI_PIPELINE_AVATAR` | pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | | **Current workflow** | | | `CI_WORKFLOW_NAME` | workflow name | `release` | | | **Current step** | | | `CI_STEP_NAME` | step name | `build package` | | `CI_STEP_TYPE` | step type (`commands`, `plugin`, `service`, `clone` or `cache`) | `commands` | | `CI_STEP_NUMBER` | step number | `0` | | `CI_STEP_STARTED` | step started UNIX timestamp | `1722617519` | | `CI_STEP_URL` | URL to step in UI | `https://ci.example.com/repos/7/pipeline/8` | | | **Previous commit** | | | `CI_PREV_COMMIT_SHA` | previous commit SHA | `15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_REF` | previous commit ref | `refs/heads/main` | | `CI_PREV_COMMIT_REFSPEC` | previous commit ref spec | `issue-branch:main` | | `CI_PREV_COMMIT_BRANCH` | previous commit branch | `main` | | `CI_PREV_COMMIT_SOURCE_BRANCH` | previous commit source branch (set only for pull request events) | `issue-branch` | | `CI_PREV_COMMIT_TARGET_BRANCH` | previous commit target branch (set only for pull request events) | `main` | | `CI_PREV_COMMIT_URL` | previous commit link in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_MESSAGE` | previous commit message | `test` | | `CI_PREV_COMMIT_AUTHOR` | previous commit author username | `john-doe` | | `CI_PREV_COMMIT_AUTHOR_EMAIL` | previous commit author email address | `john-doe@example.com` | | | **Previous pipeline** | | | `CI_PREV_PIPELINE_NUMBER` | previous pipeline number | `7` | | `CI_PREV_PIPELINE_PARENT` | previous pipeline number of parent pipeline | `0` | | `CI_PREV_PIPELINE_EVENT` | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PREV_PIPELINE_EVENT_REASON` | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PREV_PIPELINE_URL` | previous pipeline link in CI | `https://ci.example.com/repos/7/pipeline/7` | | `CI_PREV_PIPELINE_FORGE_URL` | previous pipeline link to event in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_PIPELINE_DEPLOY_TARGET` | previous pipeline deploy target for `deployment` events | `production` | | `CI_PREV_PIPELINE_DEPLOY_TASK` | previous pipeline deploy task for `deployment` events | `migration` | | `CI_PREV_PIPELINE_STATUS` | previous pipeline status | `success`, `failure` | | `CI_PREV_PIPELINE_CREATED` | previous pipeline created UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_STARTED` | previous pipeline started UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_FINISHED` | previous pipeline finished UNIX timestamp | `1722610383` | | `CI_PREV_PIPELINE_AUTHOR` | previous pipeline author username | `octocat` | | `CI_PREV_PIPELINE_AVATAR` | previous pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | |   | | | `CI_WORKSPACE` | Path of the workspace where source code gets cloned to | `/woodpecker/src/git.example.com/john-doe/my-repo` | | | **System** | | | `CI_SYSTEM_NAME` | name of the CI system | `woodpecker` | | `CI_SYSTEM_URL` | link to CI system | `https://ci.example.com` | | `CI_SYSTEM_HOST` | hostname of CI server | `ci.example.com` | | `CI_SYSTEM_VERSION` | version of the server | `2.7.0` | | | **Forge** | | | `CI_FORGE_TYPE` | name of forge | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab` | | `CI_FORGE_URL` | root URL of configured forge | `https://git.example.com` | | | **Internal** - Please don't use! | | | `CI_SCRIPT` | Internal script path. Used to call pipeline step commands. | | | `CI_NETRC_USERNAME` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_PASSWORD` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_MACHINE` | Credentials for private repos to be able to clone data. (Only available for specific images) | | ## Global environment variables If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` These can be used, for example, to manage the image tag used by multiple projects. ```ini WOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18 ``` ```diff steps: - name: build - image: golang:1.18 + image: golang:${GOLANG_VERSION} commands: - [...] ``` ## String Substitution Woodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration. Example commit substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA} ``` Example tag substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG} ``` ## String Operations Woodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values. | OPERATION | DESCRIPTION | | ------------------ | ------------------------------------------------ | | `${param}` | parameter substitution | | `${param,}` | parameter substitution with lowercase first char | | `${param,,}` | parameter substitution with lowercase | | `${param^}` | parameter substitution with uppercase first char | | `${param^^}` | parameter substitution with uppercase | | `${param:pos}` | parameter substitution with substring | | `${param:pos:len}` | parameter substitution with substring and length | | `${param=default}` | parameter substitution with default | | `${param##prefix}` | parameter substitution with prefix removal | | `${param%%suffix}` | parameter substitution with suffix removal | | `${param/old/new}` | parameter substitution with find and replace | Example variable substitution with substring: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA:0:8} ``` Example variable substitution strips `v` prefix from `v.1.0.0`: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG##v} ``` ## `pull_request_metadata` specific event reason values For the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`. **GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list. :::note Event reason values are forge-specific and may change between versions. ::: | Event | GitHub | Gitea | Forgejo | GitLab | Bitbucket | Bitbucket Datacenter | Description | | -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ | | `assigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was assigned to a user | | `converted_to_draft` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Pull request was converted to a draft | | `demilestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was removed from a milestone | | `description_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Description edited | | `edited` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | The title or body of a pull request was edited, or the base branch was changed | | `label_added` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Pull had no labels and now got label(s) added | | `label_cleared` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | All labels removed | | `label_updated` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | New label(s) added / label(s) changed | | `locked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was locked | | `milestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was added to a milestone | | `ready_for_review` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Draft pull request was marked as ready for review | | `review_requested` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | New review was requested | | `title_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Title edited | | `unassigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | User was unassigned from a pull request | | `unlabeled` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Label was removed from a pull request | | `unlocked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was unlocked | **Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214). ================================================ FILE: docs/docs/20-usage/51-plugins/20-creating-plugins.md ================================================ # Creating plugins Creating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT. ## Settings To allow users to configure the behavior of your plugin, you should use `settings:`. These are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix. Using a setting like `url` results in an env var named `PLUGIN_URL`. Characters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`. CamelCase is not respected, `anInt` get `PLUGIN_ANINT`. ### Basic settings Using any basic YAML type (scalar) will be converted into a string: | Setting | Environment value | | -------------------- | ---------------------------- | | `some-bool: false` | `PLUGIN_SOME_BOOL="false"` | | `some_String: hello` | `PLUGIN_SOME_STRING="hello"` | | `anInt: 3` | `PLUGIN_ANINT="3"` | ### Complex settings It's also possible to use complex settings like this: ```yaml steps: - name: plugin image: foo/plugin settings: complex: abc: 2 list: - 2 - 3 ``` Values like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{"abc": "2", "list": [ "2", "3" ]}`. ### Secrets Secrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage). ## Plugin library For Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See . ## Metadata In your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins). Supported metadata: - `name`: The plugin's full name - `icon`: URL to your plugin's icon - `description`: A short description of what it's doing - `author`: Your name - `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin) - `containerImage`: name of the container image - `containerImageUrl`: link to the container image - `url`: homepage or repository of your plugin If you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required. ## Example plugin This provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline. ### What end users will see The below example demonstrates how we might configure a webhook plugin in the YAML file: ```yaml steps: - name: webhook image: foo/webhook settings: url: https://example.com method: post body: | hello world ``` ### Write the logic Create a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`. ```bash #!/bin/sh curl \ -X ${PLUGIN_METHOD} \ -d ${PLUGIN_BODY} \ ${PLUGIN_URL} ``` ### Package it Create a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint. ```dockerfile # please pin the version, e.g. alpine:3.19 FROM alpine ADD script.sh /bin/ RUN chmod +x /bin/script.sh RUN apk -Uuv add curl ca-certificates ENTRYPOINT /bin/script.sh ``` Build and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community. ```shell docker build -t foo/webhook . docker push foo/webhook ``` Execute your plugin locally from the command line to verify it is working: ```shell docker run --rm \ -e PLUGIN_METHOD=post \ -e PLUGIN_URL=https://example.com \ -e PLUGIN_BODY="hello world" \ foo/webhook ``` ## Best practices - Build your plugin for different architectures to allow many users to use them. At least, you should support `amd64` and `arm64`. - Provide binaries for users using the `local` backend. These should also be built for different OS/architectures. - Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible. - Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names. - Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)). - Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)). ================================================ FILE: docs/docs/20-usage/51-plugins/51-overview.md ================================================ # Plugins Plugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline. Plugins can be used to deploy code, publish artifacts, send notification, and more. They are automatically pulled from the default container registry the agent's have configured. ```dockerfile title="Dockerfile" FROM cloud/kubectl COPY deploy /usr/local/deploy ENTRYPOINT ["/usr/local/deploy"] ``` ```bash title="deploy" kubectl apply -f $PLUGIN_TEMPLATE ``` ```yaml title=".woodpecker.yaml" steps: - name: deploy-to-k8s image: cloud/my-k8s-plugin settings: template: config/k8s/service.yaml ``` Example pipeline using the Prettier and S3 plugins: ```yaml steps: - name: build image: golang commands: - go build - go test - name: prettier image: woodpeckerci/plugin-prettier - name: publish image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file ``` ## Plugin Isolation Plugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree. While normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author. That's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically adjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands` or `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin anymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition. ## Finding Plugins For official plugins, you can use the Woodpecker plugin index: - [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins) :::tip There are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking. - [Drone Plugins](http://plugins.drone.io) - [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/) - [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community) ::: ================================================ FILE: docs/docs/20-usage/51-plugins/_category_.yaml ================================================ label: 'Plugins' # position: 2 collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/docs/20-usage/60-services.md ================================================ # Services Woodpecker provides a services section in the YAML file used for defining service containers. The below configuration composes database and cache containers. Services are accessed using custom hostnames. In the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`. ```yaml steps: - name: build image: golang commands: - go build - go test services: - name: database image: mysql - name: cache image: redis ``` You can define a port and a protocol explicitly: ```yaml services: - name: database image: mysql ports: - 3306 - name: wireguard image: wg ports: - 51820/udp ``` ## Stopping Services that are no longer needed receive a **SIGTERM** signal. If they do not respond, they are forcibly terminated with **SIGKILL**. If there are services that do not shut down properly and this doesn't matter, you can simply ignore the error: ```diff services: - name: database image: mysql + failure: ignore # we don't care how mysql exits ports: - 3306 ``` ## Configuration Service containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more. ```diff services: - name: database image: mysql + environment: + MYSQL_DATABASE: test + MYSQL_ALLOW_EMPTY_PASSWORD: yes - name: cache image: redis ``` ## Detachment Service and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required. ```diff steps: - name: build image: golang commands: - go build - go test - name: database image: redis + detach: true - name: test image: golang commands: - go test ``` Containers from detached steps will terminate when the pipeline ends. ## Initialization Service containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff. ```diff steps: - name: test image: golang commands: + - sleep 15 - go get - go test services: - name: database image: mysql ``` ## Complete Pipeline Example ```yaml services: - name: database image: mysql environment: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: example steps: - name: get-version image: ubuntu commands: - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null - sleep 30s # need to wait for mysql-server init - echo 'SHOW VARIABLES LIKE "version"' | mysql -u root -h database test -p example ``` ================================================ FILE: docs/docs/20-usage/70-volumes.md ================================================ # Volumes Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. :::note Volumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker commands: - docker build --rm -t octocat/hello-world . - docker run --rm octocat/hello-world --test - docker push octocat/hello-world - docker rmi octocat/hello-world volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` If you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`. Please note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error. ```diff -volumes: [ ./certs:/etc/ssl/certs ] +volumes: [ /etc/ssl/certs:/etc/ssl/certs ] ``` ================================================ FILE: docs/docs/20-usage/72-extensions/40-configuration-extension.md ================================================ # Configuration extension The configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Preprocess the original configuration file with something like Go templating - Convert custom attributes to Woodpecker attributes - Add defaults to the configuration like default steps - Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ... - Centralize configuration for multiple repositories in one place ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your configuration extension. The global configuration will be called before the repository specific configuration extension if both are configured and the repository has not enabled the exclusive setting. ```ini title="Server" WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://example.com/ciconfig ``` ## How it works When a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used. You can enable the exclusive setting (both globally and on a per-repo level). Then Woodpecker will only call your extension, but nothing else. This allows you to completely skip the forge. Requests sent to the extension will not have the configuration files added. ### Request The extension receives an HTTP POST request with the following JSON payload: :::info The `netrc` field is only included in the request when the global `WOODPECKER_CONFIG_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked. ::: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc?: Netrc; // only included when netrc sending is enabled (see above) configuration?: { // list of configurations. Not send if there was none. name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "configuration": [ { "name": ".woodpecker.yaml", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from Repo (.woodpecker.yaml)\"\n" } ], "netrc": { "machine": "myforge.com", "login": "myUser", "password": "forge-access-token" } } ``` ### Response The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. ```ts class Response { configs: { name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Example response: ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ================================================ FILE: docs/docs/20-usage/72-extensions/50-registry-extension.md ================================================ # Registry extension Woodpecker uses the registry extension to get registry credentials. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Centralize registry credential management - Use an external storage for credentials - Dynamically manage which credentials Woodpecker should use ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your registry extension. If both the global and the repo-level extension return credentials for a registry, it will use the credentials from the repo extension. ```ini title="Server" WOODPECKER_REGISTRY_EXTENSION_ENDPOINT=https://example.com/ciconfig ``` ## How it works When a pipeline is triggered, Woodpecker will fetch the credentials from your service. As fallback, it uses the credentials configured directly in Woodpecker. ### Request The extension receives an HTTP POST request with the following JSON payload: :::info The `netrc` field is only included in the request when the global `WOODPECKER_REGISTRY_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked. ::: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc?: Netrc; // only included when netrc sending is enabled (see above) } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json // Please check the latest structure in the models mentioned above. // This example is likely outdated. { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "myforge.com", "login": "myUser", "password": "forge-access-token" } } ``` ### Response The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. ```ts class Response { registries: { address: string; // the docker registry address username: string; // registry username password: string; // registry password }[]; } ``` Example response: ```json { "registries": [ { "address": "docker.io", "username": "woodpecker-bot", "password": "your-pass-word-123" } ] } ``` ================================================ FILE: docs/docs/20-usage/72-extensions/55-secret-extension.md ================================================ # Secret extension Woodpecker uses the secret extension to get secrets from an external service. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Centralize secret management (e.g. HashiCorp Vault, AWS Secrets Manager) - Dynamically generate secrets per pipeline ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the security section. ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your secret extension. If both the global and the repo-level extension return a secret with the same name, it will use the secret from the repo extension. ```ini title="Server" WOODPECKER_SECRET_EXTENSION_ENDPOINT=https://example.com/secrets WOODPECKER_SECRET_EXTENSION_NETRC=false ``` ## How it works When a pipeline is triggered, Woodpecker will fetch secrets from your service. The extension secrets are merged with the secrets configured directly in Woodpecker, with extension secrets taking priority by name. If the extension is unavailable, Woodpecker falls back to the locally configured secrets. ### Request The extension receives an HTTP POST request with the following JSON payload: :::info The `netrc` field is only included in the request when the global `WOODPECKER_SECRET_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked. ::: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc?: Netrc; // only included when netrc sending is enabled (see above) } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json // Please check the latest structure in the models mentioned above. // This example is likely outdated. { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "myforge.com", "login": "myUser", "password": "forge-access-token" } } // Note: the "netrc" field is omitted when netrc sending is not enabled. ``` ### Response The extension should respond with a JSON object containing a `secrets` array. If the extension wants to keep the existing secrets without adding any, it can respond with HTTP status `204 No Content`. ```ts class Response { secrets: { name: string; // the secret name, matched by from_secret in pipeline config value: string; // the secret value images?: string[]; // optional: restrict to specific plugins events?: string[]; // optional: restrict to specific pipeline events }[]; } ``` Example response: ```json { "secrets": [ { "name": "docker_password", "value": "your-secret-password-123" }, { "name": "deploy_token", "value": "super-secret-token", "events": ["push", "tag"] } ] } ``` ## 3rd Party Extensions :::danger These extensions are neither developed nor verified by Woodpecker CI. Make sure you trust them before using. ::: - [OpenBao extension](https://github.com/vcheesbrough/woodpecker-openbao-broker) ================================================ FILE: docs/docs/20-usage/72-extensions/_category_.yaml ================================================ label: 'Extensions' # position: 3 collapsible: true collapsed: true link: type: 'doc' id: 'index' ================================================ FILE: docs/docs/20-usage/72-extensions/index.md ================================================ # Extensions Woodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints. There is currently one type of extension available: - [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly. - [Registry extension](./50-registry-extension.md) to get registry credentials from the extension. - [Secret extension](./55-secret-extension.md) to get secrets from an external service. ## Security :::warning You need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful data like malicious pipeline configurations that could be executed. ::: To prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair. To verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign). You can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page. ## Example extensions A simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions) ## Configuration To prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of: - Built-in networks: - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included. - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet. - `external`: A valid non-private unicast IP, you can access all hosts on public internet. - `*`: All hosts are allowed. - CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6 - (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*` ================================================ FILE: docs/docs/20-usage/72-linter.md ================================================ # Linter Woodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines. ![errors and warnings in UI](./linter-warnings-errors.png) ## Running the linter from CLI You can run the linter also manually from the CLI: ```shell woodpecker-cli lint ``` ## Bad habit warnings Woodpecker warns you if your configuration contains some bad habits. ### Event filter for all steps All your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well. Examples of an **incorrect** config for this rule: ```yaml when: - branch: main - event: tag ``` This will trigger the warning because the first item (`branch: main`) does not filter with an event. ```yaml steps: - name: test when: branch: main - name: deploy when: event: tag ``` Examples of a **correct** config for this rule: ```yaml when: - branch: main event: push - event: tag ``` ```yaml steps: - name: test when: event: [tag, push] - name: deploy when: - event: tag ``` ================================================ FILE: docs/docs/20-usage/75-project-settings.md ================================================ # Project settings As the owner of a project in Woodpecker you can change project related settings via the web interface. ![project settings](./project-settings.png) ## Pipeline path The path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`. ## Repository hooks Your Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting. ## Allow pull requests Enables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests. ## Allow deployments Enables a pipeline to be started with the `deploy` event from a successful pipeline. :::danger Only activate this option if you trust all users who have push access to your repository. Otherwise, these users will be able to steal secrets that are only available for `deploy` events. ::: ## Require approval for To prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`. ## Trusted If you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes. :::note Only server admins can set this option. If you are not a server admin this option won't be shown in your project settings. ::: ## Custom trusted clone plugins During the clone process, Git credentials (e.g., for private repositories) may be required. These credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html). These credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting. With these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo. To prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level. Without an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step. :::info This setting does not affect subsequent steps, nor does it allow direct pushes to the repository. To enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push). ::: ## Project visibility You can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners. - `Public` Every user can see your project without being logged in. - `Internal` Only authenticated users of the Woodpecker instance can see this project. - `Private` Only you and other owners of the repository can see this project. ## Timeout After this timeout a pipeline has to finish or will be treated as timed out. ## Cancel previous pipelines By enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one. ================================================ FILE: docs/docs/20-usage/80-badges.md ================================================ # Status Badges Woodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code. ## Badge endpoint ```uri :///api/badges//status.svg ``` The status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter. ```diff -:///api/badges//status.svg +:///api/badges//status.svg?branch= ``` By default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. If you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event: ```diff -:///api/badges//status.svg +:///api/badges//status.svg?events=manual,cron ``` ================================================ FILE: docs/docs/20-usage/90-advanced-usage.md ================================================ # Advanced usage ## Advanced YAML syntax YAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config: ### Anchors & aliases You can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config. To convert this: ```yaml steps: - name: test image: golang:1.18 commands: go test ./... - name: build image: golang:1.18 commands: build ``` Just add a new section called **variables** like this: ```diff +variables: + - &golang_image 'golang:1.18' steps: - name: test - image: golang:1.18 + image: *golang_image commands: go test ./... - name: build - image: golang:1.18 + image: *golang_image commands: build ``` ### Map merges and overwrites ```yaml variables: - &base-plugin-settings target: dist recursive: false try: true - &special-setting special: true - &some-plugin codeberg.org/6543/docker-images/print_env steps: - name: develop image: *some-plugin settings: <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map when: branch: develop - name: main image: *some-plugin settings: <<: *base-plugin-settings # merge one map and ... try: false # ... overwrite original value ongoing: false # ... adding a new value when: branch: main ``` ### Sequence merges ```yaml variables: pre_cmds: &pre_cmds - echo start - whoami post_cmds: &post_cmds - echo stop hello_cmd: &hello_cmd - echo hello steps: - name: step1 image: debian commands: - <<: *pre_cmds # prepend a sequence - echo exec step now do dedicated things - <<: *post_cmds # append a sequence - name: step2 image: debian commands: - <<: [*pre_cmds, *hello_cmd] # prepend two sequences - echo echo from second step - <<: *post_cmds ``` ### References - [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) - [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml) ## Persisting environment data between steps One can create a file containing environment variables, and then source it in each step that needs them. ```yaml steps: - name: init image: bash commands: - echo "FOO=hello" >> envvars - echo "BAR=world" >> envvars - name: debug image: bash commands: - source ./envvars - echo $FOO ``` ## Declaring global variables As described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables: ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` Note that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps. ## Docker in docker (dind) setup :::warning This set up will only work on trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable "trusted" mode. ::: The snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service. :::note If your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead. ::: First we need to define a service running a docker with the `dind` tag. This service must run in `privileged` mode: ```yaml services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true ports: - 2376 ``` Next, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28). This can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below). ```diff services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true + environment: + DOCKER_TLS_CERTDIR: /dind-certs + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` In the docker client step: 1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon. These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them). 2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`) Test the connection with the docker client: ```diff steps: - name: test image: docker:cli # in production use something like 'docker:-cli' + environment: + DOCKER_HOST: "tcp://docker:2376" + DOCKER_CERT_PATH: "/dind-certs/client" + DOCKER_TLS_VERIFY: "1" + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version ``` This step should output the server and client version information if everything has been set up correctly. Full example: ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/dind-certs/client' DOCKER_TLS_VERIFY: '1' volumes: - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true environment: DOCKER_TLS_CERTDIR: /dind-certs volumes: - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` ================================================ FILE: docs/docs/20-usage/_category_.yaml ================================================ label: 'Usage' # position: 2 collapsible: true collapsed: false ================================================ FILE: docs/docs/30-administration/00-general.md ================================================ # General Woodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`). The **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files. The **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance. The **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time). :::tip You can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent. ::: ## Database Woodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page. ## Forge What would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page. ## Container images :::info No `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch. ::: - `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image) - `vX.Y` - `vX` - `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0). - `vX.Y-alpine` - `vX-alpine` - `next`: Built from the `main` branch - `pull_`: Images built from Pull Request branches. Images are pushed to DockerHub and Quay. - woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server)) - woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent)) - woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli)) - woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler)) ================================================ FILE: docs/docs/30-administration/05-installation/10-docker-compose.md ================================================ # Docker Compose This example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings. It creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information. The server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it. ```yaml title="docker-compose.yaml" services: woodpecker-server: image: woodpeckerci/woodpecker-server:v3 ports: - 8000:8000 volumes: - woodpecker-server-data:/var/lib/woodpecker/ environment: - WOODPECKER_OPEN=true - WOODPECKER_HOST=${WOODPECKER_HOST} - WOODPECKER_GITHUB=true - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: image: woodpeckerci/woodpecker-agent:v3 command: agent restart: always depends_on: - woodpecker-server volumes: - woodpecker-agent-config:/etc/woodpecker - /var/run/docker.sock:/var/run/docker.sock environment: - WOODPECKER_SERVER=woodpecker-server:9000 - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} volumes: woodpecker-server-data: woodpecker-agent-config: ``` Woodpecker must know its own address. You must therefore specify the public address in the format `://`. Please omit any trailing slashes: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_HOST=${WOODPECKER_HOST} ``` It is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR} + - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR} ``` If the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: - [...] + - WOODPECKER_GRPC_SECURE=true # defaults to false + - WOODPECKER_GRPC_VERIFY=true # default ``` As agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] + volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` Agents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: + - WOODPECKER_SERVER=woodpecker-server:9000 ``` The server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Handling sensitive data There are several options for handling sensitive data in `docker compose` or `docker swarm` configurations: For Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure. Alternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret + secrets: + - woodpecker-agent-secret + + secrets: + woodpecker-agent-secret: + external: true ``` To store values in a docker secret you can use the following command: ```bash echo "my_agent_secret_key" | docker secret create woodpecker-agent-secret - ``` ## SELinux Considerations If you're running Woodpecker on a system with SELinux enabled (RHEL, CentOS, Fedora, etc.), you may need to add the `:z` or `:Z` option to volume mounts. For the Docker socket volume: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock:z ``` For more details and other SELinux-related solutions, see the [Troubleshooting](../../20-usage/100-troubleshooting.md#selinux-issues) page. ================================================ FILE: docs/docs/30-administration/05-installation/20-helm-chart.md ================================================ # Helm Chart Woodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments: ```bash helm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version ``` ## Metrics To enable metrics gathering, set the following in values.yml: ```yaml metrics: enabled: true port: 9001 ``` This activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics. To enable both Prometheus pod monitoring discovery, set: ```yaml prometheus: podmonitor: enabled: true interval: 60s labels: {} ``` If you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled: ```yaml # Search all available namespaces podMonitorNamespaceSelector: matchLabels: {} # Enable all available pod monitors podMonitorSelector: matchLabels: {} ``` ================================================ FILE: docs/docs/30-administration/05-installation/30-packages.md ================================================ # Distribution packages ## Official packages - DEB - RPM The pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution. ```Shell RELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '"tag_name":\s"v\K[^"]+') # Debian/Ubuntu (x86_64) curl -fLOOO "https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb" sudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb # CentOS/RHEL (x86_64) sudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm ``` The package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values. ```ini title="/usr/local/lib/systemd/system/woodpecker-server.service" [Unit] Description=WoodpeckerCI server Documentation=https://woodpecker-ci.org/docs/administration/server-config Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env ConditionPathExists=/etc/woodpecker/woodpecker-server.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-server.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-server WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-server.env" WOODPECKER_OPEN=true WOODPECKER_HOST=${WOODPECKER_HOST} WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` After installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server. ```ini title="/usr/local/lib/systemd/system/woodpecker-agent.service" [Unit] Description=WoodpeckerCI agent Documentation=https://woodpecker-ci.org/docs/administration/configuration/agent Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env ConditionPathExists=/etc/woodpecker/woodpecker-agent.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-agent.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-agent WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-agent.env" WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Community packages :::info Woodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions. ::: - [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=) - [Arch Linux](https://archlinux.org/packages/?q=woodpecker) - [openSUSE](https://software.opensuse.org/package/woodpecker) - [YunoHost](https://apps.yunohost.org/app/woodpecker) - [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html) - [Easypanel](https://easypanel.io/docs/templates/woodpeckerci) - [Homebrew](https://formulae.brew.sh/formula/woodpecker-cli) (CLI only) ### NixOS :::info This module is not maintained by the Woodpecker developers. If you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained. ::: In theory, the NixOS installation is very similar to the binary installation and supports multiple backends. In practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken. ```nix { config , ... }: let domain = "woodpecker.example.org"; in { # This automatically sets up certificates via let's encrypt security.acme.defaults.email = "acme@example.com"; security.acme.acceptTerms = true; # Setting up a nginx proxy that handles tls for us services.nginx = { enable = true; openFirewall = true; recommendedTlsSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; virtualHosts."${domain}" = { enableACME = true; forceSSL = true; locations."/".proxyPass = "http://localhost:3007"; }; }; services.woodpecker-server = { enable = true; environment = { WOODPECKER_HOST = "https://${domain}"; WOODPECKER_SERVER_ADDR = ":3007"; WOODPECKER_OPEN = "true"; }; # You can pass a file with env vars to the system it could look like: # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX environmentFile = "/path/to/my/secrets/file"; }; # This sets up a woodpecker agent services.woodpecker-agents.agents."docker" = { enable = true; # We need this to talk to the podman socket extraGroups = [ "podman" ]; environment = { WOODPECKER_SERVER = "localhost:9000"; WOODPECKER_MAX_WORKFLOWS = "4"; DOCKER_HOST = "unix:///run/podman/podman.sock"; WOODPECKER_BACKEND = "docker"; }; # Same as with woodpecker-server environmentFile = [ "/var/lib/secrets/woodpecker.env" ]; }; # Here we setup podman and enable dns virtualisation.podman = { enable = true; defaultNetwork.settings = { dns_enabled = true; }; }; # This is needed for podman to be able to talk over dns networking.firewall.interfaces."podman0" = { allowedUDPPorts = [ 53 ]; allowedTCPPorts = [ 53 ]; }; } ``` All configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline. ================================================ FILE: docs/docs/30-administration/05-installation/_category_.yaml ================================================ label: 'Installation' collapsible: true collapsed: true ================================================ FILE: docs/docs/30-administration/10-configuration/10-server.md ================================================ --- toc_max_heading_level: 3 --- # Server ## Forge and User configuration Woodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge. You can also restrict the registration: - closed registration and manually managing users with the CLI `woodpecker-cli user` - open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN` ```ini WOODPECKER_OPEN=false WOODPECKER_ADMIN=john.smith,jane_doe ``` - open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS` ```ini WOODPECKER_OPEN=true WOODPECKER_ORGS=dolores,dog-patch ``` Administrators should also be explicitly set in your configuration. ```ini WOODPECKER_ADMIN=john.smith,jane_doe ``` ## Repository configuration Woodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here. ```ini WOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user ``` ## Databases The default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind: - Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`. - Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably. - Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes. - Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice. ### SQLite By default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] + volumes: + - woodpecker-server-data:/var/lib/woodpecker/ ``` ### MySQL/MariaDB The below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples. The minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information. ```ini WOODPECKER_DATABASE_DRIVER=mysql WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true ``` ### PostgreSQL The below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples. Please use Postgres versions equal or higher than **11**. ```ini WOODPECKER_DATABASE_DRIVER=postgres WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable ``` ## TLS Woodpecker supports SSL configuration by mounting certificates into your container. ```ini WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key ``` TLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library. ### Container configuration In addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] ports: + - 80:80 + - 443:443 - 9000:9000 ``` Additionally, the certificate and key must be mounted and referenced: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: + - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt + - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key volumes: + - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt + - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key ``` ## Reverse Proxy ### Apache This guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration: ```apacheconf ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` You must have these Apache modules installed: - `proxy` - `proxy_http` You must configure Apache to set `X-Forwarded-Proto` when using https. ```diff ProxyPreserveHost On +RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` ### Nginx This guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide). Example configuration: ```nginx server { listen 80; server_name woodpecker.example.com; location / { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` You must configure the proxy to set `X-Forwarded` proxy headers: ```diff server { listen 80; server_name woodpecker.example.com; location / { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` ### Caddy This guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration: ```caddy # expose WebUI and API woodpecker.example.com { reverse_proxy woodpecker-server:8000 } # expose gRPC woodpecker-agent.example.com { reverse_proxy h2c://woodpecker-server:9000 } ``` ### Tunnelmole [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool. Start by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation). After the installation, run the following command to start tunnelmole: ```bash tmole 8000 ``` It will start a tunnel and will give a response like this: ```bash ➜ ~ tmole 8000 http://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 https://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 ``` Set `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server. ### Ngrok [Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command: ```bash ngrok http 8000 ``` Set `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server. ### Traefik To install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https. ```yaml services: server: image: woodpeckerci/woodpecker-server:latest environment: - WOODPECKER_OPEN=true - WOODPECKER_ADMIN=your_admin_user # other settings ... networks: - dmz # externally defined network, so that traefik can connect to the server volumes: - woodpecker-server-data:/var/lib/woodpecker/ deploy: labels: - traefik.enable=true # web server - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000 - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker-secure.tls=true - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-secure.service=woodpecker-service - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker.entrypoints=web - traefik.http.routers.woodpecker.service=woodpecker-service - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker # gRPC service - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000 - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc-secure.tls=true - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc.entrypoints=web - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker volumes: woodpecker-server-data: driver: local networks: dmz: external: true ``` ## Metrics ### Endpoint Woodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above. ```yaml global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` ### Authorization An administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` As an alternative, the token can also be read from a file: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token_file: /etc/secrets/woodpecker-monitoring-token static_configs: - targets: ['woodpecker.domain.com'] ``` ### Reference List of Prometheus metrics specific to Woodpecker: ```yaml # HELP woodpecker_pipeline_count Pipeline count. # TYPE woodpecker_pipeline_count counter woodpecker_pipeline_count{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 woodpecker_pipeline_count{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 # HELP woodpecker_pipeline_time Build time. # TYPE woodpecker_pipeline_time gauge woodpecker_pipeline_time{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 116 woodpecker_pipeline_time{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 155 # HELP woodpecker_pipeline_total_count Total number of builds. # TYPE woodpecker_pipeline_total_count gauge woodpecker_pipeline_total_count 1025 # HELP woodpecker_pending_steps Total number of pending pipeline steps. # TYPE woodpecker_pending_steps gauge woodpecker_pending_steps 0 # HELP woodpecker_repo_count Total number of repos. # TYPE woodpecker_repo_count gauge woodpecker_repo_count 9 # HELP woodpecker_running_steps Total number of running pipeline steps. # TYPE woodpecker_running_steps gauge woodpecker_running_steps 0 # HELP woodpecker_user_count Total number of users. # TYPE woodpecker_user_count gauge woodpecker_user_count 1 # HELP woodpecker_waiting_steps Total number of pipeline waiting on deps. # TYPE woodpecker_waiting_steps gauge woodpecker_waiting_steps 0 # HELP woodpecker_worker_count Total number of workers. # TYPE woodpecker_worker_count gauge woodpecker_worker_count 4 ``` #### Example response structure ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ## UI customization Woodpecker supports custom JS and CSS files. These files must be present in the server's filesystem. They can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment. The configuration variables are independent of each other, which means it can be just one file present, or both. ```ini WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js ``` The examples below show how to place a banner message in the top navigation bar of Woodpecker. ```css title="woodpecker.css" .banner-message { position: absolute; width: 280px; height: 40px; margin-left: 240px; margin-top: 5px; padding-top: 5px; font-weight: bold; background: red no-repeat; text-align: center; } ``` ```javascript title="woodpecker.js" // place/copy a minified version of your preferred lightweight JavaScript library here ... !(function () { 'use strict'; function e() {} /*...*/ })(); $().ready(function () { $('.app nav img').first().htmlAfter(""); }); ``` ## Environment variables ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### LOG_FILE - Name: `WOODPECKER_LOG_FILE` - Default: `stderr` Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. --- ### DATABASE_LOG - Name: `WOODPECKER_DATABASE_LOG` - Default: `false` Enable logging in database engine (currently xorm). --- ### DATABASE_LOG_SQL - Name: `WOODPECKER_DATABASE_LOG_SQL` - Default: `false` Enable logging of sql commands. --- ### DATABASE_MAX_CONNECTIONS - Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS` - Default: `100` Max database connections xorm is allowed create. --- ### DATABASE_IDLE_CONNECTIONS - Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS` - Default: `2` Amount of database connections xorm will hold open. --- ### DATABASE_CONNECTION_TIMEOUT - Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT` - Default: `3 Seconds` Time an active database connection is allowed to stay open. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOST - Name: `WOODPECKER_HOST` - Default: none Server fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix. Examples: - `WOODPECKER_HOST=http://woodpecker.example.org` - `WOODPECKER_HOST=http://example.org/woodpecker` - `WOODPECKER_HOST=http://example.org:1234/woodpecker` --- ### SERVER_ADDR - Name: `WOODPECKER_SERVER_ADDR` - Default: `:8000` Configures the HTTP listener port. --- ### SERVER_ADDR_TLS - Name: `WOODPECKER_SERVER_ADDR_TLS` - Default: `:443` Configures the HTTPS listener port when SSL is enabled. --- ### SERVER_CERT - Name: `WOODPECKER_SERVER_CERT` - Default: none Path to an SSL certificate used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_CERT=/path/to/cert.pem` --- ### SERVER_KEY - Name: `WOODPECKER_SERVER_KEY` - Default: none Path to an SSL certificate key used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_KEY=/path/to/key.pem` --- ### CUSTOM_CSS_FILE - Name: `WOODPECKER_CUSTOM_CSS_FILE` - Default: none File path for the server to serve a custom .CSS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css` --- ### CUSTOM_JS_FILE - Name: `WOODPECKER_CUSTOM_JS_FILE` - Default: none File path for the server to serve a custom .JS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js` --- ### GRPC_ADDR - Name: `WOODPECKER_GRPC_ADDR` - Default: `:9000` Configures the gRPC listener port. --- ### GRPC_SECRET - Name: `WOODPECKER_GRPC_SECRET` - Default: `secret` Configures the gRPC JWT secret. --- ### GRPC_SECRET_FILE - Name: `WOODPECKER_GRPC_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GRPC_SECRET` from the specified filepath. --- ### METRICS_SERVER_ADDR - Name: `WOODPECKER_METRICS_SERVER_ADDR` - Default: none Configures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely. Example: `:9001` --- ### ADMIN - Name: `WOODPECKER_ADMIN` - Default: none Comma-separated list of admin accounts. Example: `WOODPECKER_ADMIN=user1,user2` --- ### ORGS - Name: `WOODPECKER_ORGS` - Default: none Comma-separated list of approved organizations. Example: `org1,org2` --- ### REPO_OWNERS - Name: `WOODPECKER_REPO_OWNERS` - Default: none Repositories by those owners will be allowed to be used in woodpecker. Example: `user1,user2` --- ### OPEN - Name: `WOODPECKER_OPEN` - Default: `false` Enable to allow user registration. --- ### AUTHENTICATE_PUBLIC_REPOS - Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS` - Default: `false` Always use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies. --- ### DEFAULT_ALLOW_PULL_REQUESTS - Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS` - Default: `true` The default setting for allowing pull requests on a repo. --- ### DEFAULT_APPROVAL_MODE - Name: `WOODPECKER_DEFAULT_APPROVAL_MODE` - Default: `forks` The default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`. --- ### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS - Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS` - Default: `pull_request, push` List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created. --- ### DEFAULT_CLONE_PLUGIN - Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN` - Default: `docker.io/woodpeckerci/plugin-git` The default docker image to be used when cloning the repo. It is also added to the trusted clone plugin list. ### DEFAULT_WORKFLOW_LABELS - Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS` - Default: none You can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set. Example: `platform=linux/amd64,backend=docker` ### DEFAULT_PIPELINE_TIMEOUT - Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT` - Default: 60 The default time for a repo in minutes before a pipeline gets killed ### MAX_PIPELINE_TIMEOUT - Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT` - Default: 120 The maximum time in minutes you can set in the repo settings before a pipeline gets killed --- ### SESSION_EXPIRES - Name: `WOODPECKER_SESSION_EXPIRES` - Default: `72h` Configures the session expiration time. Context: when someone does log into Woodpecker, a temporary session token is created. As long as the session is valid (until it expires or log-out), a user can log into Woodpecker, without re-authentication. ### PLUGINS_PRIVILEGED - Name: `WOODPECKER_PLUGINS_PRIVILEGED` - Default: none Docker images to run in privileged mode. Only change if you are sure what you do! You should specify the tag of your images too, as this enforces exact matches. ### PLUGINS_TRUSTED_CLONE - Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE` - Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git` Plugins which are trusted to handle the Git credential info in clone steps. If a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos. You should specify the tag of your images too, as this enforces exact matches. --- ### DOCKER_CONFIG - Name: `WOODPECKER_DOCKER_CONFIG` - Default: none Configures a specific private registry config for all pipelines. Example: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json` --- ### ENVIRONMENT - Name: `WOODPECKER_ENVIRONMENT` - Default: none If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. Example: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2` --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath --- ### DISABLE_USER_AGENT_REGISTRATION - Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION` - Default: false By default, users can create new agents for their repos they have admin access to. If an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements. :::note You should set this option if you have, for example, global secrets and don't trust your users to create a rogue agent and pipeline for secret extraction. ::: --- ### KEEPALIVE_MIN_TIME - Name: `WOODPECKER_KEEPALIVE_MIN_TIME` - Default: none Server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping. Example: `WOODPECKER_KEEPALIVE_MIN_TIME=10s` --- ### DATABASE_DRIVER - Name: `WOODPECKER_DATABASE_DRIVER` - Default: `sqlite3` The database driver name. Possible values are `sqlite3`, `mysql` or `postgres`. --- ### DATABASE_DATASOURCE - Name: `WOODPECKER_DATABASE_DATASOURCE` - Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container The database connection string. The default value is the path of the embedded SQLite database file. Example: ```bash # MySQL # https://github.com/go-sql-driver/mysql#dsn-data-source-name WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true # PostgreSQL # https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable ``` --- ### DATABASE_DATASOURCE_FILE - Name: `WOODPECKER_DATABASE_DATASOURCE_FILE` - Default: none Read the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath --- ### PROMETHEUS_AUTH_TOKEN - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN` - Default: none Token to secure the Prometheus metrics endpoint. Must be set to enable the endpoint. --- ### PROMETHEUS_AUTH_TOKEN_FILE - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE` - Default: none Read the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath --- ### STATUS_CONTEXT - Name: `WOODPECKER_STATUS_CONTEXT` - Default: `ci/woodpecker` Context prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository. --- ### STATUS_CONTEXT_FORMAT - Name: `WOODPECKER_STATUS_CONTEXT_FORMAT` - Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}` Template for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language. Supported variables: - `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`) - `event`: the event which started the pipeline - `workflow`: the workflow's name - `owner`: the repo's owner - `repo`: the repo's name --- ### CONFIG_EXTENSION_ENDPOINT - Name: `WOODPECKER_CONFIG_EXTENSION_ENDPOINT` - Default: none Specify a configuration extension endpoint, see [Configuration Extension](../../20-usage/72-extensions/40-configuration-extension.md) --- ### CONFIG_EXTENSION_EXCLUSIVE - Name: `CONFIG_EXTENSION_EXCLUSIVE` - Default: false Whether the forge request should be skipped for the global configuration endpoint. :::warning If you enable this, all repos will exclusively use the global config service endpoint. There is no possibility to directly define pipelines in the forge, except the extension handles this case itself as well. ::: --- ### CONFIG_EXTENSION_NETRC - Name: `WOODPECKER_CONFIG_EXTENSION_NETRC` - Default: false Send `netrc` to the config extension endpoint. :::warning The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository. ::: --- ### SECRET_EXTENSION_ENDPOINT - Name: `WOODPECKER_SECRET_EXTENSION_ENDPOINT` - Default: none Specify a secret extension endpoint, see [Secret Extension](../../20-usage/72-extensions/55-secret-extension.md) --- ### SECRET_EXTENSION_NETRC - Name: `WOODPECKER_SECRET_EXTENSION_NETRC` - Default: false Send `netrc` to the secret extension endpoint. :::warning The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository. ::: --- ### REGISTRY_EXTENSION_ENDPOINT - Name: `WOODPECKER_REGISTRY_EXTENSION_ENDPOINT` - Default: none Specify a registry extension endpoint, see [Registry Extension](../../20-usage/72-extensions/50-registry-extension.md) --- ### REGISTRY_EXTENSION_NETRC - Name: `WOODPECKER_REGISTRY_EXTENSION_NETRC` - Default: false Send `netrc` to the registry extension endpoint. :::warning The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository. ::: --- ### EXTENSIONS_ALLOWED_HOSTS - Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` - Default: `external` Comma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list. --- ### FORGE_TIMEOUT - Name: `WOODPECKER_FORGE_TIMEOUT` - Default: 5s Specify timeout when fetching the Woodpecker configuration from forge. See for syntax reference. --- ### FORGE_RETRY - Name: `WOODPECKER_FORGE_RETRY` - Default: 3 Specify how many retries of fetching the Woodpecker configuration from a forge are done before we fail. --- ### ENABLE_SWAGGER - Name: `WOODPECKER_ENABLE_SWAGGER` - Default: true Enable the Swagger UI for API documentation. --- ### DISABLE_VERSION_CHECK - Name: `WOODPECKER_DISABLE_VERSION_CHECK` - Default: false Disable version check in admin web UI. --- ### LOG_STORE - Name: `WOODPECKER_LOG_STORE` - Default: `database` Where to store logs. Possible values: - `database`: stores the logs in the database - `file`: stores logs in JSON files on the files system - `addon`: uses an [addon](./100-addons.md#log) to store logs --- ### LOG_STORE_FILE_PATH - Name: `WOODPECKER_LOG_STORE_FILE_PATH` - Default: none If [`WOODPECKER_LOG_STORE`](#log_store) is: - `file`: Directory to store logs in - `addon`: The path to the addon executable --- ### EXPERT_WEBHOOK_HOST - Name: `WOODPECKER_EXPERT_WEBHOOK_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `://[/]`. --- ### EXPERT_FORGE_OAUTH_HOST - Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified public forge URL, used if forge url is not a public URL. Format: `://[/]`. --- ### FORCE_IGNORE_SERVICE_FAILURE - Name: `WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE` - Default: true :::warning Since v3.14.0, Woodpecker can report the status of services and detached steps. Because these can now fail, until v4.0.0 is released, service failures are ignored by default to preserve backward compatibility. We encourage you to disable this option and update your pipeline configuration. ::: --- ### GITHUB\_\* See [GitHub configuration](./12-forges/20-github.md#configuration) --- ### GITEA\_\* See [Gitea configuration](./12-forges/30-gitea.md#configuration) --- ### BITBUCKET\_\* See [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration) --- ### GITLAB\_\* See [GitLab configuration](./12-forges/40-gitlab.md#configuration) ================================================ FILE: docs/docs/30-administration/10-configuration/100-addons.md ================================================ # Addons Addons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service. :::warning Addon forges are still experimental. Their implementation can change and break at any time. ::: :::danger You must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information. ::: ## Usage To use an addon forge, download the correct addon version. ### Forge Use this in your `.env`: ```ini WOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file ``` In case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`. #### List of addon forges - [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws). ### Log Use this in your `.env`: ```ini WOODPECKER_LOG_STORE=addon WOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file ``` ## Developing addon forges See [Addons](../../92-development/100-addons.md). ================================================ FILE: docs/docs/30-administration/10-configuration/11-backends/10-docker.md ================================================ --- toc_max_heading_level: 2 --- # Docker This is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent. ## Private registries Woodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config). To add your credential helper to the Woodpecker server container you could use the following code to build a custom image: ```dockerfile FROM woodpeckerci/woodpecker-server:latest-alpine RUN apk add -U --no-cache docker-credential-ecr-login ``` ## Step specific configuration ### Run user By default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group: ```yaml steps: - name: example image: alpine commands: - whoami backend_options: docker: user: 65534:65534 ``` The syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag. ## Tips and tricks ### Image cleanup The agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner. :::danger The following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation. ::: - Remove all unused images ```bash docker image rm $(docker images --filter "dangling=true" -q --no-trunc) ``` - Remove Woodpecker volumes ```bash docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true -q) ``` ### Podman There is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog). ## Environment variables ### BACKEND_DOCKER_NETWORK - Name: `WOODPECKER_BACKEND_DOCKER_NETWORK` - Default: none Set to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other! --- ### BACKEND_DOCKER_ENABLE_IPV6 - Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6` - Default: `false` Enable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6. --- ### BACKEND_DOCKER_VOLUMES - Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES` - Default: none List of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA certificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`. --- ### BACKEND_DOCKER_LIMIT_MEM_SWAP - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP` - Default: `0` The maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_MEM - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM` - Default: `0` The maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_SHM_SIZE - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE` - Default: `0` The maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_QUOTA - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA` - Default: `0` The number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_SHARES - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES` - Default: `0` The relative weight vs. other containers. --- ### BACKEND_DOCKER_LIMIT_CPU_SET - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET` - Default: none Comma-separated list to limit the specific CPUs or cores a pipeline container can use. Example: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2` ================================================ FILE: docs/docs/30-administration/10-configuration/11-backends/20-kubernetes.md ================================================ --- toc_max_heading_level: 2 --- # Kubernetes The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps. ## Metadata labels Woodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies. The following metadata labels are supported: - `woodpecker-ci.org/forge-id` - `woodpecker-ci.org/repo-forge-id` - `woodpecker-ci.org/repo-id` - `woodpecker-ci.org/repo-name` - `woodpecker-ci.org/repo-full-name` - `woodpecker-ci.org/branch` - `woodpecker-ci.org/org-id` - `woodpecker-ci.org/task-uuid` - `woodpecker-ci.org/step` ## Private registries In addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML. Place these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`. ## Step specific configuration ### Resources The Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory. We recommend to add a `resources` definition to all steps to ensure efficient scheduling. Here is an example definition with an arbitrary `resources` definition below the `backend_options` section: ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: resources: requests: memory: 200Mi cpu: 100m limits: memory: 400Mi cpu: 1000m ``` You can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis. ### Runtime class `runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes. ### Service account `serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts. ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: # Use the service account `default` in the current namespace. # This usually the same as wherever woodpecker is deployed. serviceAccountName: default ``` To give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) ### Node selector `nodeSelector` specifies the labels which are used to select the node on which the step will be executed. Labels defined here will be appended to a list which already contains `"kubernetes.io/arch"`. By default `"kubernetes.io/arch"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`. Without a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures. To overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`. A practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture. In this case, one must define an arbitrary key in the matrix section of the respective matrix element: ```yaml matrix: include: - NAME: runner1 ARCH: arm64 ``` And then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var: ```yaml [...] backend_options: kubernetes: nodeSelector: kubernetes.io/arch: "${ARCH}" ``` You can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent or [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis. ### Tolerations When you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations. Example pipeline configuration: ```yaml steps: - name: build image: golang commands: - go get - go build - go test backend_options: kubernetes: serviceAccountName: 'my-service-account' resources: requests: memory: 128Mi cpu: 1000m limits: memory: 256Mi nodeSelector: beta.kubernetes.io/instance-type: Standard_D2_v3 tolerations: - key: 'key1' operator: 'Equal' value: 'value1' effect: 'NoSchedule' tolerationSeconds: 3600 affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: topology.kubernetes.io/zone operator: In values: - eu-central-1a - eu-central-1b ``` ### Affinity Kubernetes [affinity and anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity) rules allow you to constrain which nodes your pods can be scheduled on based on node labels, or co-locate/spread pods relative to other pods. You can configure affinity at two levels: 1. **Per-step via `backend_options.kubernetes.affinity`** (shown in example above) - requires agent configuration to allow it 2. **Agent-wide via `WOODPECKER_BACKEND_K8S_POD_AFFINITY`** - applies to all pods unless overridden #### Agent-wide affinity To apply affinity rules to all workflow pods, configure the agent with YAML-formatted affinity: ```yaml WOODPECKER_BACKEND_K8S_POD_AFFINITY: | nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node-role.kubernetes.io/worker operator: In values: - "true" ``` By default, per-step affinity settings are **not allowed** for security reasons. To enable them: ```bash WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP: true ``` :::warning Enabling `WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP` in multi-tenant environments allows pipeline authors to control pod placement, which may have security or resource isolation implications. ::: When per-step affinity is allowed and specified, it **replaces** the agent-wide affinity entirely (not merged). #### Example: agent affinity for co-location This example configures all workflow pods within a workflow to be co-located on the same node, while requiring other workflows run on different nodes. It uses `matchLabelKeys` to dynamically match pods with the same `woodpecker-ci.org/task-uuid`, and `mismatchLabelKeys` to separating pods with different task UUIDs: ```yaml WOODPECKER_BACKEND_K8S_POD_AFFINITY: | podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: {} matchLabelKeys: - woodpecker-ci.org/task-uuid topologyKey: "kubernetes.io/hostname" podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: {} mismatchLabelKeys: - woodpecker-ci.org/task-uuid topologyKey: "kubernetes.io/hostname" ``` :::note The `matchLabelKeys` and `mismatchLabelKeys` features require Kubernetes v1.29+ (alpha with feature gate `MatchLabelKeysInPodAffinity`) or v1.33+ (beta, enabled by default). These fields allow the Kubernetes API server to dynamically populate label selectors at pod creation time, eliminating the need to hardcode values like `$(WOODPECKER_TASK_UUID)`. ::: #### Example: Node affinity for GPU workloads Ensure a step runs only on GPU-enabled nodes: ```yaml steps: - name: train-model image: tensorflow/tensorflow:latest-gpu backend_options: kubernetes: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: accelerator operator: In values: - nvidia-tesla-v100 ``` ### Volumes To mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option. Persistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference. _If your PVC is not highly available or NFS-based, use the `affinity` settings (documented above) to ensure that your steps are executed on the correct node._ NOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver: ```yaml accessModes: - ReadWriteMany ``` Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step: ```yaml steps: - name: "Restore Cache" image: meltwater/drone-cache volumes: - woodpecker-cache:/woodpecker/src/cache settings: mount: - "woodpecker-cache" [...] ``` Or as follows when using a normal image: ```yaml steps: - name: "Edit cache" image: alpine:latest volumes: - woodpecker-cache:/woodpecker/src/cache commands: - echo "Hello World" > /woodpecker/src/cache/output.txt [...] ``` ### Security context Use the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step: ```yaml steps: - name: test image: alpine commands: - echo Hello world backend_options: kubernetes: securityContext: runAsUser: 999 runAsGroup: 999 privileged: true [...] ``` Note that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object. By default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the configuration shown above will result in something like the following Pod spec: ```yaml kind: Pod spec: securityContext: runAsUser: 999 runAsGroup: 999 containers: - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0 image: alpine securityContext: privileged: true [...] ``` You can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile. ```yaml backend_options: kubernetes: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/audit.json ``` or restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile ```yaml backend_options: kubernetes: securityContext: apparmorProfile: type: Localhost localhostProfile: k8s-apparmor-example-deny-write ``` or configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always') ```yaml backend_options: kubernetes: securityContext: fsGroupChangePolicy: OnRootMismatch ``` :::note The feature requires Kubernetes v1.30 or above. ::: You can set `allowPrivilegeEscalation` to `false` to prevent a container from gaining more privileges than its parent process. ```yaml backend_options: kubernetes: securityContext: allowPrivilegeEscalation: false ``` You can also drop [Linux capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) from a container. Adding capabilities is not allowed. ```yaml backend_options: kubernetes: securityContext: capabilities: drop: - ALL ``` ### Annotations and labels You can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration: ```yaml backend_options: kubernetes: annotations: workflow-group: alpha io.kubernetes.cri-o.Devices: /dev/fuse labels: environment: ci app.kubernetes.io/name: builder ``` In order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent: [WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step). ## Tips and tricks ### CRI-O CRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration: ```yaml workspace: base: '/woodpecker' path: '/' ``` See [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details. ### `KUBERNETES_SERVICE_HOST` environment variable Like the below env vars used for configuration, this can be set in the environment for configuration of the agent. It configures the address of the Kubernetes API server to connect to. If running the agent within Kubernetes, this will already be set and you don't have to add it manually. ### Headless services For each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created, and all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname. Using the headless services, the step pod is connected to directly, so any port on the other step pods can be reached. This is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service. ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/woodpecker/dind-certs/client' DOCKER_TLS_VERIFY: '1' commands: - docker run hello-world - name: docker image: docker:dind # use 'docker:-dind' or similar in production detached: true privileged: true environment: DOCKER_TLS_CERTDIR: /woodpecker/dind-certs ``` If ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP. ## Environment variables These env vars can be set in the `env:` sections of the agent. --- ### BACKEND_K8S_NAMESPACE - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE` - Default: `woodpecker` The namespace to create worker Pods in. --- ### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION` - Default: `false` Enables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation. With this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker. ### BACKEND_K8S_VOLUME_SIZE - Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE` - Default: `10G` The volume size of the pipeline volume. --- ### BACKEND_K8S_STORAGE_CLASS - Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS` - Default: none The storage class to use for the pipeline volume. --- ### BACKEND_K8S_STORAGE_RWX - Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX` - Default: `true` Determines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead. --- ### BACKEND_K8S_POD_LABELS - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS` - Default: none Additional labels to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-label":"test-value"}`. --- ### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP` - Default: `false` Determines if additional Pod labels can be defined from a step's backend options. --- ### BACKEND_K8S_POD_ANNOTATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS` - Default: none Additional annotations to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-annotation":"test-value"}`. --- ### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP` - Default: `false` Determines if Pod annotations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_TOLERATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS` - Default: none Additional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{"effect":"NoSchedule","key":"jobs","operator":"Exists"}]`. --- ### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP` - Default: `true` Determines if Pod tolerations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_NODE_SELECTOR - Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR` - Default: none Additional node selector to apply to worker pods. Must be a YAML object, e.g. `{"topology.kubernetes.io/region":"eu-central-1"}`. --- ### BACKEND_K8S_SECCTX_NONROOT - Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT` - Default: `false` Determines if containers must be required to run as non-root users. --- ### BACKEND_K8S_PULL_SECRET_NAMES - Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` - Default: none Secret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/). --- ### BACKEND_K8S_PRIORITY_CLASS - Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS` - Default: none, which will use the default priority class configured in Kubernetes Which [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods. ================================================ FILE: docs/docs/30-administration/10-configuration/11-backends/30-local.md ================================================ --- toc_max_heading_level: 2 --- # Local :::danger The local backend executes pipelines on the local system without any isolation. ::: :::note Currently we do not support [services](../../../20-usage/60-services.md) for this backend. [Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095). ::: Since the commands run directly in the same context as the agent (same user, same filesystem), a malicious pipeline could be used to access the agent configuration especially the `WOODPECKER_AGENT_SECRET` variable. It is recommended to use this backend only for private setup where the code and pipeline can be trusted. It should not be used in a public instance where anyone can submit code or add new repositories. The agent should not run as a privileged user (root). The local backend will use a random directory in `$TMPDIR` to store the cloned code and execute commands. In order to use this backend, you need to download (or build) the [agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine. ## Step specific configuration ### Shell The `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is used to run the commands. ```yaml title=".woodpecker.yaml" steps: - name: build image: bash commands: [...] ``` ### Plugins ```yaml steps: - name: build image: /usr/bin/tree ``` If no commands are provided, plugins are treated in the usual manner. In the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path. ## Environment variables ### BACKEND_LOCAL_TEMP_DIR - Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` - Default: default temp directory Directory to create folders for workflows. ================================================ FILE: docs/docs/30-administration/10-configuration/11-backends/50-custom.md ================================================ # Custom If none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend: ```go package main import ( "go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core" backendTypes "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func main() { core.RunAgent([]backendTypes.Backend{ yourBackend, }) } ``` ================================================ FILE: docs/docs/30-administration/10-configuration/11-backends/_category_.yaml ================================================ label: 'Backends' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/11-overview.md ================================================ # Forges ## Supported features | Feature | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) | | ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- | | Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | Event: Deploy¹ | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | | [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | [Multiple workflows](../../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [when.path filter](../../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | ¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks. In addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core. ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/20-github.md ================================================ --- toc_max_heading_level: 2 --- # GitHub Woodpecker comes with built-in support for GitHub and GitHub Enterprise. To use Woodpecker with GitHub the following environment variables should be set for the server component: ```ini WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID WOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET ``` You will get these values from GitHub when you register your OAuth application. To do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App. :::warning Do not use a "GitHub App" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically) ::: ## App Settings - Name: An arbitrary name for your App - Homepage URL: The URL of your Woodpecker instance - Callback URL: `https:///authorize` - (optional) Upload the Woodpecker Logo: ## Client Secret Creation After your App has been created, you can generate a client secret. Use this one for the `WOODPECKER_GITHUB_SECRET` environment variable. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITHUB - Name: `WOODPECKER_GITHUB` - Default: `false` Enables the GitHub driver. --- ### GITHUB_URL - Name: `WOODPECKER_GITHUB_URL` - Default: `https://github.com` Configures the GitHub server address. --- ### GITHUB_CLIENT - Name: `WOODPECKER_GITHUB_CLIENT` - Default: none Configures the GitHub OAuth client id to authorize access. --- ### GITHUB_CLIENT_FILE - Name: `WOODPECKER_GITHUB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath. --- ### GITHUB_SECRET - Name: `WOODPECKER_GITHUB_SECRET` - Default: none Configures the GitHub OAuth client secret. This is used to authorize access. --- ### GITHUB_SECRET_FILE - Name: `WOODPECKER_GITHUB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath. --- ### GITHUB_MERGE_REF - Name: `WOODPECKER_GITHUB_MERGE_REF` - Default: `true` --- ### GITHUB_SKIP_VERIFY - Name: `WOODPECKER_GITHUB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### GITHUB_PUBLIC_ONLY - Name: `WOODPECKER_GITHUB_PUBLIC_ONLY` - Default: `false` Configures the GitHub OAuth client to only obtain a token that can manage public repositories. ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/30-gitea.md ================================================ --- toc_max_heading_level: 2 --- # Gitea Woodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITEA=true WOODPECKER_GITEA_URL=YOUR_GITEA_URL WOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT WOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET ``` ## Gitea on the same host with containers If you have Gitea also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `gitea`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea ``` ## Registration ### User OAuth Application Register your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. ### System-wide OAuth Application If you are the administrator of both Gitea and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Gitea site administrator level and are visible to all users. To create a system-wide OAuth application in Gitea: 1. Navigate to the site administration settings at `https://gitea./admin/settings/applications` 2. Create a new OAuth2 application under the "OAuth2 Applications" section 3. Configure the application with the same settings as above (callback URL, etc.) 4. Use the generated client id and secret for Woodpecker configuration System-wide applications are particularly useful for: - Shared CI/CD environments where multiple users need Woodpecker access - Organizations that want centralized control over OAuth applications - Preventing user-level application quotas from affecting CI/CD operations ### Local Connections If you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook). ![gitea oauth setup](gitea_oauth.gif) :::warning Make sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITEA - Name: `WOODPECKER_GITEA` - Default: `false` Enables the Gitea driver. --- ### GITEA_URL - Name: `WOODPECKER_GITEA_URL` - Default: `https://try.gitea.io` Configures the Gitea server address. --- ### GITEA_CLIENT - Name: `WOODPECKER_GITEA_CLIENT` - Default: none Configures the Gitea OAuth client id. This is used to authorize access. --- ### GITEA_CLIENT_FILE - Name: `WOODPECKER_GITEA_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath --- ### GITEA_SECRET - Name: `WOODPECKER_GITEA_SECRET` - Default: none Configures the Gitea OAuth client secret. This is used to authorize access. --- ### GITEA_SECRET_FILE - Name: `WOODPECKER_GITEA_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITEA_SECRET` from the specified filepath --- ### GITEA_SKIP_VERIFY - Name: `WOODPECKER_GITEA_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/35-forgejo.md ================================================ --- toc_max_heading_level: 2 --- # Forgejo Woodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_FORGEJO=true WOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL WOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT WOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET ``` ## Forgejo on the same host with containers If you have Forgejo also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `forgejo`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo ``` ## Registration ### User OAuth Application Register your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. ### System-wide OAuth Application If you are the administrator of both Forgejo and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Forgejo site administrator level and are visible to all users. To create a system-wide OAuth application in Forgejo: 1. Navigate to the site administration settings at `https://forgejo./admin/settings/applications` 2. Create a new OAuth2 application under the "OAuth2 Applications" section 3. Configure the application with the same settings as above (callback URL, etc.) 4. Use the generated client id and secret for Woodpecker configuration System-wide applications are particularly useful for: - Shared CI/CD environments where multiple users need Woodpecker access - Organizations that want centralized control over OAuth applications - Preventing user-level application quotas from affecting CI/CD operations ### Local Connections If you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook). ![forgejo oauth setup](gitea_oauth.gif) :::warning Make sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### FORGEJO - Name: `WOODPECKER_FORGEJO` - Default: `false` Enables the Forgejo driver. --- ### FORGEJO_URL - Name: `WOODPECKER_FORGEJO_URL` - Default: `https://next.forgejo.org` Configures the Forgejo server address. --- ### FORGEJO_CLIENT - Name: `WOODPECKER_FORGEJO_CLIENT` - Default: none Configures the Forgejo OAuth client id. This is used to authorize access. --- ### FORGEJO_CLIENT_FILE - Name: `WOODPECKER_FORGEJO_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath --- ### FORGEJO_SECRET - Name: `WOODPECKER_FORGEJO_SECRET` - Default: none Configures the Forgejo OAuth client secret. This is used to authorize access. --- ### FORGEJO_SECRET_FILE - Name: `WOODPECKER_FORGEJO_SECRET_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath --- ### FORGEJO_SKIP_VERIFY - Name: `WOODPECKER_FORGEJO_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/40-gitlab.md ================================================ --- toc_max_heading_level: 2 --- # GitLab Woodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITLAB=true WOODPECKER_GITLAB_URL=http://gitlab.mycompany.com WOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82 WOODPECKER_GITLAB_SECRET=30f5064039e6b359e075 ``` ## Registration You must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application. Please use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application. If you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITLAB - Name: `WOODPECKER_GITLAB` - Default: `false` Enables the GitLab driver. --- ### GITLAB_URL - Name: `WOODPECKER_GITLAB_URL` - Default: `https://gitlab.com` Configures the GitLab server address. --- ### GITLAB_CLIENT - Name: `WOODPECKER_GITLAB_CLIENT` - Default: none Configures the GitLab OAuth client id. This is used to authorize access. --- ### GITLAB_CLIENT_FILE - Name: `WOODPECKER_GITLAB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath --- ### GITLAB_SECRET - Name: `WOODPECKER_GITLAB_SECRET` - Default: none Configures the GitLab OAuth client secret. This is used to authorize access. --- ### GITLAB_SECRET_FILE - Name: `WOODPECKER_GITLAB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath --- ### GITLAB_SKIP_VERIFY - Name: `WOODPECKER_GITLAB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/50-bitbucket.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Woodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_BITBUCKET=true WOODPECKER_BITBUCKET_CLIENT=... # called "Key" in Bitbucket WOODPECKER_BITBUCKET_SECRET=... ``` ## Registration You must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`). Please set a name and set the `Callback URL` like this: ```uri https:///authorize ``` ![bitbucket oauth setup](bitbucket_oauth.png) Please also be sure to check the following permissions: - Account: Email, Read - Workspace membership: Read - Projects: Read - Repositories: Read - Pull requests: Read - Webhooks: Read and Write ![bitbucket permissions](bitbucket_permissions.png) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET - Name: `WOODPECKER_BITBUCKET` - Default: `false` Enables the Bitbucket driver. --- ### BITBUCKET_CLIENT - Name: `WOODPECKER_BITBUCKET_CLIENT` - Default: none Configures the Bitbucket OAuth client key. This is used to authorize access. --- ### BITBUCKET_CLIENT_FILE - Name: `WOODPECKER_BITBUCKET_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath --- ### BITBUCKET_SECRET - Name: `WOODPECKER_BITBUCKET_SECRET` - Default: none Configures the Bitbucket OAuth client secret. This is used to authorize access. --- ### BITBUCKET_SECRET_FILE - Name: `WOODPECKER_BITBUCKET_SECRET_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath ## Known Issues Bitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details. ## Missing Features Path filters for pull requests are not supported. We are interested in patches to include this functionality. If you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de). ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Datacenter / Server :::warning Woodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash. ::: To enable Bitbucket Server you should configure the Woodpecker container using the following environment variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_BITBUCKET_DC=true + - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo + - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar + - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx + - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy + - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com + - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true woodpecker-agent: [...] ``` ## Service Account Woodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories. ## Registration Woodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose "Application Links" and then "Create link". Woodpecker should be listed as "External Application" and the direction should be set to "Incoming". Note the client id and client secret of the registration to be used in the configuration of Woodpecker. See also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html). ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET_DC - Name: `WOODPECKER_BITBUCKET_DC` - Default: `false` Enables the Bitbucket Server driver. --- ### BITBUCKET_DC_URL - Name: `WOODPECKER_BITBUCKET_DC_URL` - Default: none Configures the Bitbucket Server address. --- ### BITBUCKET_DC_CLIENT_ID - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID` - Default: none Configures your Bitbucket Server OAUth 2.0 client id. --- ### BITBUCKET_DC_CLIENT_SECRET - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET` - Default: none Configures your Bitbucket Server OAUth 2.0 client secret. --- ### BITBUCKET_DC_GIT_USERNAME - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` - Default: none This username is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_USERNAME_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath --- ### BITBUCKET_DC_GIT_PASSWORD - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` - Default: none The password is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_PASSWORD_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath --- ### BITBUCKET_DC_SKIP_VERIFY - Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN - Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN` - Default: `false` When enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization. ================================================ FILE: docs/docs/30-administration/10-configuration/12-forges/_category_.yaml ================================================ label: 'Forges' collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/docs/30-administration/10-configuration/30-agent.md ================================================ --- toc_max_heading_level: 3 --- # Agent Agents are configured by the command line or environment variables. At the minimum you need the following information: ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" ``` The following are automatically set and can be overridden: - `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname - `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1 ## Workflows per agent By default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent. ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" WOODPECKER_MAX_WORKFLOWS=4 ``` ## Agent registration When the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before. There are two types of tokens to connect an agent to the server: ### Using system token A _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents. In that case registration process would be as following: 1. The first time the agent communicates with the server, it is using the system token 1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent 1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`) 1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server ### Using agent token An _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`. To get an _agent token_ you have to register the agent manually in the server using the UI: 1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent` ![Agent creation](./new-agent-registration.png) ![Agent created](./new-agent-created.png) 1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET` 1. The agent will connect to the server using the provided token and will update its status in the UI: ![Agent connected](./new-agent-connected.png) ## Environment variables ### SERVER - Name: `WOODPECKER_SERVER` - Default: `localhost:9000` Configures gRPC address of the server. --- ### USERNAME - Name: `WOODPECKER_USERNAME` - Default: `x-oauth-basic` The gRPC username. --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf` --- ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOSTNAME - Name: `WOODPECKER_HOSTNAME` - Default: none Configures the agent hostname. --- ### AGENT_CONFIG_FILE - Name: `WOODPECKER_AGENT_CONFIG_FILE` - Default: `/etc/woodpecker/agent.conf` Configures the path of the agent config file. --- ### MAX_WORKFLOWS - Name: `WOODPECKER_MAX_WORKFLOWS` - Default: `1` Configures the number of parallel workflows. --- ### AGENT_SINGLE_WORKFLOW - Name: `WOODPECKER_AGENT_SINGLE_WORKFLOW` - Default: `false` Configures the agent to exit (shutdown) after executing one workflow. When configured, `WOODPECKER_MAX_WORKFLOWS` is forced to 1. This one-shot mode is useful in ephemeral environments that are provisioned on demand by external automation — for example, when an autoscaler spins up a dedicated machine. In these setups, the agent starts, executes exactly one workflow, and exits, allowing the environment to be cleanly torn down afterward. --- ### AGENT_LABELS - Name: `WOODPECKER_AGENT_LABELS` - Default: none Configures custom labels for the agent, to let workflows filter by it. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. If you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched. By default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels). --- ### HEALTHCHECK - Name: `WOODPECKER_HEALTHCHECK` - Default: `true` Enable healthcheck endpoint. --- ### HEALTHCHECK_ADDR - Name: `WOODPECKER_HEALTHCHECK_ADDR` - Default: `:3000` Configures healthcheck endpoint address. --- ### KEEPALIVE_TIME - Name: `WOODPECKER_KEEPALIVE_TIME` - Default: none After a duration of this time of no activity, the agent pings the server to check if the transport is still alive. --- ### KEEPALIVE_TIMEOUT - Name: `WOODPECKER_KEEPALIVE_TIMEOUT` - Default: `20s` After pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity. --- ### GRPC_SECURE - Name: `WOODPECKER_GRPC_SECURE` - Default: `false` Configures if the connection to `WOODPECKER_SERVER` should be made using a secure transport. --- ### GRPC_VERIFY - Name: `WOODPECKER_GRPC_VERIFY` - Default: `true` Configures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`. --- ## RETRY_TIMEOUT - Name: `WOODPECKER_RETRY_TIMEOUT` - Default: `2m` Set how long the agent keeps retrying to reconnect to the server after the gRPC connection is lost before giving up. :::warning If set to 0 we retry forever. ::: --- ### BACKEND - Name: `WOODPECKER_BACKEND` - Default: `auto-detect` Configures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`. ### BACKEND_DOCKER\_\* See [Docker backend configuration](./11-backends/10-docker.md#environment-variables) --- ### BACKEND_K8S\_\* See [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables) --- ### BACKEND_LOCAL\_\* See [Local backend configuration](./11-backends/30-local.md#environment-variables) ### Advanced Settings :::warning Only change these If you know what you do. ::: #### CONNECT_RETRY_COUNT - Name: `WOODPECKER_CONNECT_RETRY_COUNT` - Default: `5` Configures number of times agent retries to connect to the server. #### CONNECT_RETRY_DELAY - Name: `WOODPECKER_CONNECT_RETRY_DELAY` - Default: `2s` Configures delay between agent connection retries to the server. ================================================ FILE: docs/docs/30-administration/10-configuration/40-autoscaler.md ================================================ # Autoscaler If your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler). Please note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap). ## Setup ### docker compose If you are using docker compose you can add the following to your `docker-compose.yaml` file: ```yaml services: woodpecker-server: image: woodpeckerci/woodpecker-server:next [...] woodpecker-autoscaler: image: woodpeckerci/autoscaler:next restart: always depends_on: - woodpecker-server environment: - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user - WOODPECKER_MIN_AGENTS=0 - WOODPECKER_MAX_AGENTS=3 - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include "https://" in the value. - WOODPECKER_GRPC_SECURE=true - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud ``` ================================================ FILE: docs/docs/30-administration/10-configuration/_category_.yaml ================================================ label: 'Configuration' collapsible: true collapsed: true ================================================ FILE: docs/docs/30-administration/_category_.yaml ================================================ label: 'Administration' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/docs/92-development/01-getting-started.md ================================================ # Getting started You can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea). ## Gitpod If you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing: - An IDE in the browser or bridged to your local VS-Code or Jetbrains - A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge - A pre-configured Woodpecker server - A single pre-configured Woodpecker agent node - Our docs preview server Start Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`. [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker) ## Preparation for local development ### Install Go Install Golang as described by [this guide](https://go.dev/doc/install). ### Install make > GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (). Install make on: - Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/) - [Windows](https://stackoverflow.com/a/32127632/8461267) - Mac OS: `brew install make` ### Install Node.js & `pnpm` Install [Node.js](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation. For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used. [This guide](https://pnpm.io/installation) describes the installation of `pnpm`. ### Install `pre-commit` (optional) Woodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code. To apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage). ### Create a `.env` file with your development configuration Similar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it. A common config for debugging would look like this: ```ini WOODPECKER_OPEN=true WOODPECKER_ADMIN=your-username WOODPECKER_HOST=http://localhost:8000 # github (sample for a forge config - see /docs/administration/forge/overview for other forges) WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT= WOODPECKER_GITHUB_SECRET= # agent WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system WOODPECKER_MAX_WORKFLOWS=1 # enable if you want to develop the UI # WOODPECKER_DEV_WWW_PROXY=http://localhost:8010 # if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server WOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com # disable health-checks while debugging (normally not needed while developing) WOODPECKER_HEALTHCHECK=false # WOODPECKER_LOG_LEVEL=debug # WOODPECKER_LOG_LEVEL=trace ``` ### Setup OAuth Create an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md). ## Developing with VS Code You can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it. To launch all needed services for local development, you can use "Woodpecker CI" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it. As a starting guide for programming Go with VS Code, you can use this video guide: [![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80) ### Debugging Woodpecker The Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points. ![Woodpecker debugging with VS Code](./vscode-debug.png) ## Testing & linting code To test or lint parts of Woodpecker, you can run one of the following commands: ```bash # test server code make test-server # test agent code make test-agent # test cli code make test-cli # test datastore / database related code like migrations of the server make test-server-datastore # lint go code make lint # lint UI code make lint-frontend # test UI code make test-frontend ``` If you want to test a specific Go file, you can also use: ```bash go test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/ ``` Or you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands: ![Run test via VS-Code](./vscode-run-test.png) ## Run applications from terminal If you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor. ```bash title="start server" go run ./cmd/server ``` ```bash title="start agent" go run ./cmd/agent ``` ```bash title="execute cli command" go run ./cmd/cli [command] ``` ================================================ FILE: docs/docs/92-development/02-core-ideas.md ================================================ # Core ideas - A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂). - If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle). - What is used most often should be default. - Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md). ## Addons and extensions If you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an [addon](../30-administration/10-configuration/100-addons.md), [extension](../20-usage/72-extensions/40-configuration-extension.md) or an [external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points: - Is your change very specific to your setup and unlikely to be used by anyone else? - Does your change violate the [guidelines](#guidelines)? Both should be false when you open a pull request to get your change into the core repository. ### Guidelines #### Forges A new forge must support these features: - OAuth2 - Webhooks ================================================ FILE: docs/docs/92-development/03-ui.md ================================================ # UI Development To develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api. ## Setup The UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed). Testing UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files. ![UI Proxy architecture](./ui-proxy.svg) Start the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file. After starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000). ### Usage with remote server If you would like to test your UI changes on a "real-world" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables: - `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org` - `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser Then, open the UI at `http://localhost:8010`. ## Tools and frameworks The following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing. - [Vue 3](https://v3.vuejs.org/) - use `setup` and composition api - place (re-usable) components in `web/src/components/` - views should have a route in `web/src/router.ts` and are located in `web/src/views/` - [Tailwind CSS](https://tailwindcss.com/) - use Tailwind classes where possible - if needed extend the Tailwind config to use new classes - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier) - [Vite](https://vitejs.dev/) (similar to Webpack) - [Typescript](https://www.typescriptlang.org/) - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:) - [eslint](https://eslint.org/) - [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471) ## Messages and Translations Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source. You must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet) For more information about translations see [Translations](./08-translations.md). ================================================ FILE: docs/docs/92-development/04-docs.md ================================================ # Documentation The documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/). If you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands: ```bash cd docs/ pnpm install # build plugins used by the docs pnpm build:woodpecker-plugins # start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually pnpm start # or build the docs to deploy it to some static page hosting pnpm build ``` ================================================ FILE: docs/docs/92-development/05-architecture.md ================================================ # Architecture ## Module Interactions ![Woodpecker architecture](./woodpecker-architecture.svg) ## System architecture ### main package hierarchy | package | meaning | imports | | ------------------ | -------------------------------------------------------------- | ------------------------------------- | | `cmd/**` | parse command-line args & environment to stat server/cli/agent | all other | | `agent/**` | code only agent (remote worker) will need | `pipeline`, `rpc`, `shared` | | `cli/**` | code only cli tool does need | `pipeline`, `shared`, `woodpecker-go` | | `server/**` | code only server will need | `pipeline`, `rpc`, `shared` | | `pipeline/**` | core ci/cd engine from parsing to execution | `shared` | | `rpc/**` | RPC interface for agent-server communication | `pipeline` | | `shared/**` | code shared for all three main tools (go help utils) | only std and external libs | | `woodpecker-go/**` | go client for server rest api | std | ### Server | package | meaning | imports | | -------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server/api/**` | handle web requests from `server/router` | `pipeline`, `rpc`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) | | `server/badges/**` | generate svg badges for pipelines | `../model` | | `server/ccmenu/**` | generate xml ccmenu for pipelines | `../model` | | `server/rpc/**` | gRPC server agents can connect to | `rpc`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store` | | `server/logging/**` | logging lib for gPRC server to stream logs while running | std | | `server/model/**` | structs for store (db) and api (json) | std | | `server/pipeline/**` | orchestrate pipelines (TODO: parts of it should move into /pipeline) | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins` | | `server/pubsub/**` | pubsub lib for server to push changes to the WebUI | std | | `server/queue/**` | queue lib for server where agents pull new pipelines from via gRPC | `server/model` | | `server/forge/**` | forge lib for server to connect and handle forge specific stuff | `shared`, `server/model` | | `server/router/**` | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web` | | `server/store/**` | handle database | `server/model` | | `server/web/**` | server SPA | | - `../` = `server/` ### Agent | package | meaning | imports | | -------------- | ---------------------------------------------------- | ------------------------------------------------------ | | `agent/**` | agent implementation that runs workflows | `pipeline`, `rpc`, `shared` | | `agent/rpc/**` | gRPC client for agent-server communication | `rpc`, `pipeline/backend/types`, std and external libs | | `cmd/agent/**` | CLI interface for starting and configuring the agent | `agent`, std and external libs | The agent is a remote worker that connects to the server via gRPC to receive pipeline execution instructions and report back execution state and logs. The agent polls the server's queue for new work, executes pipeline steps using the pipeline engine, and streams results back to the server. TODO: Review cmd/agent/core to determine if any logic should be moved into the agent package for better separation of concerns. ### CLI | package | meaning | imports | | ------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `cli/admin/**` | admin commands for server management (users, secrets, registries, etc.) | `../common`, `../internal`, `woodpecker-go` | | `cli/common/**` | shared utilities and helpers used across all CLI subcommands | `../internal/config`, `../update`, `shared` | | `cli/context/**` | manage multiple server contexts (connections to different servers) | `../common`, `../internal/config`, `../output` | | `cli/exec/**` | execute pipelines locally without server orchestration | `pipeline`, `../common`, `../lint`, `shared` | | `cli/info/**` | display information about the current user | `../common`, `../internal` | | `cli/internal/**` | internal utilities for HTTP client, auth, and server communication | `../internal/config`, `woodpecker-go`, `shared` | | `cli/internal/config/**` | configuration file management (load, store, credentials) | std and external libs | | `cli/lint/**` | validate pipeline configuration files | `pipeline/frontend/yaml`, `pipeline/frontend/yaml/linter`, `../common`, `shared` | | `cli/org/**` | manage organization-level resources (secrets, registries) | `../common`, `../internal`, `woodpecker-go` | | `cli/output/**` | formatting utilities for CLI output (tables, etc.) | std and external libs | | `cli/pipeline/**` | manage pipeline operations (start, stop, approve, logs, etc.) | `../common`, `../internal`, `../output`, `woodpecker-go`, `shared` | | `cli/repo/**` | manage repository-level resources (repos, crons, secrets, registries) | `../common`, `../internal`, `../output`, `woodpecker-go` | | `cli/setup/**` | interactive first-time setup wizard for CLI configuration | `../internal/config` | | `cli/update/**` | self-updater for the CLI binary | std and external libs | | `cmd/cli/**` | CLI entry point and command structure | `cli/**` | The CLI provides a command-line interface for interacting with Woodpecker servers. Each subcommand is organized into its own package under `cli//`. The `cli/exec` subcommand allows local pipeline execution for testing and development by combining pipeline parsing and execution without requiring a running server or agent. - `../` = `cli/` ### Engine The engine is the shared kernel that validates, parses frontend facing config files, enrich it by the provided forge metadata and produce config for the backends to execute on based on that. It also contains the default backend implementations. #### Runtime The runtime is the package controlling how a workflow is executed, and can be found at `pipeline/runtime`. Pipeline/runtime flow diagram ================================================ FILE: docs/docs/92-development/06-conventions.md ================================================ # Conventions ## Database naming Database tables are named plural, columns don't have any prefix. Example: Model name `Agent` with table name `agents` and columns `id`, `name`. ================================================ FILE: docs/docs/92-development/07-guides.md ================================================ # Guides ## ORM Woodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection. ## Add a new migration Woodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`. :::info Adding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created. ::: :::warning You should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager. ::: To automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start. ## Constants of official images All official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag. ## Building images locally ### Server ```sh ### build web component make vendor cd web/ pnpm install --frozen-lockfile pnpm build cd .. ### define the platforms to build for (e.g. linux/amd64) # (the | is not a typo here) export PLATFORMS='linux|amd64' make cross-compile-server ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push . ``` :::info The `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)). You can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS). ::: ### Agent ```sh ### build the agent make build-agent ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push . ``` ### CLI ```sh ### build the CLI make build-cli ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push . ``` ================================================ FILE: docs/docs/92-development/08-translations.md ================================================ # Translations To translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.** Translation status Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. ================================================ FILE: docs/docs/92-development/09-openapi.md ================================================ # Swagger, API Spec and Code Generation Woodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically generate Swagger v2 API specifications and a nice looking Web UI from the source code. Also, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger) and then being using on the community's website documentation. It's paramount important to keep the gin handler function's godoc documentation up-to-date, to always have accurate API documentation. Whenever you change, add or enhance an API endpoint, please update the godoc. You don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools. ## Gin-Handler API documentation guideline Here's a typical example of how annotations for Swagger documentation look like... ```go title="server/api/user.go" // @Summary Get a user // @Description Returns a user with the specified login name. Requires admin rights. // @Router /users/{login} [get] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param foobar query string false "optional foobar parameter" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) ``` ```go title="server/model/user.go" type User struct { ID int64 `json:"id" xorm:"pk autoincr 'user_id'"` // ... } // @name User ``` These guidelines aim to have consistent wording in the OpenAPI doc: - first word after `@Summary` and `@Summary` are always uppercase - `@Summary` has no `.` (dot) at the end of the line - model structs shall use custom short names, to ease life for API consumers, using `@name` - `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI - when pagination is used, `@Param page` and `@Param perPage` must be added manually - `@Param Authorization` is almost always present, there are just a few un-protected endpoints There are many examples in the `server/api` package, which you can use a blueprint. More enhanced information you can find here ### Manual code generation ```bash title="generate the server's Go code containing the OpenAPI" make generate-openapi ``` ```bash title="update the Markdown in the ./docs folder" make generate-docs ``` ================================================ FILE: docs/docs/92-development/09-testing.md ================================================ # Testing ## Backend ### Unit Tests [We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test) with [`"github.com/stretchr/testify/assert"`](https://pkg.go.dev/github.com/stretchr/testify/assert) to simplify testing. ### Integration Tests ### Dummy backend There is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave. To enable it you need to build the agent or cli with the `test` build tag. An example pipeline config would be: ```yaml when: event: manual steps: - name: echo image: dummy commands: echo "hello woodpecker" environment: SLEEP: '1s' services: echo: image: dummy commands: echo "i am a service" ``` This could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`: ```none 9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: service [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9 [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: commands [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 ``` There are also environment variables to alter step behavior: - `SLEEP: 10` will let the step wait 10 seconds - `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands` - `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled) - `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs - `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0 - `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains You can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`. ================================================ FILE: docs/docs/92-development/10-packaging.md ================================================ # Packaging If you repackage it, we encourage to build from source, which requires internet connection. For offline builds, we also offer a tarball with all vendored dependencies and a pre-built web UI on the [release page](https://github.com/woodpecker-ci/woodpecker/releases). ## Distribute web UI in own directory If you do not want to embed the web UI in the binary, you can compile a custom root path for the web UI into the binary. Add `external_web` to the tags and use the build flag `-X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/some/path` to set a custom path. Example: ```sh go build -tags 'external_web' -ldflags '-s -w -extldflags "-static" -X go.woodpecker-ci.org/woodpecker/v3/version.Version=3.12.0 -X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/nix/store/maaajlp8h5gy9zyjgfhaipzj07qnnmrl-woodpecker-WebUI-3.12.0' -o dist/woodpecker-server go.woodpecker-ci.org/woodpecker/v3/cmd/server ``` ================================================ FILE: docs/docs/92-development/100-addons.md ================================================ # Addons The Woodpecker server supports addons for forges and the log store. :::warning Addons are still experimental. Their implementation can change and break at any time. ::: ## Bug reports If you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name. ## Creating addons Addons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin). ### Writing your code This example will use the Go language. Directly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there. In the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument. This will take care of connecting the addon forge to the server. :::note It is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process. ::: ### Example structure This is an example for a forge addon. ```go package main import ( "context" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/forge/addon" forgeTypes "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func main() { addon.Serve(config{}) } type config struct { } // `config` must implement `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge`. You must directly use Woodpecker's packages - see imports above. ``` ### Addon types | Type | Addon package | Service interface | | --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | | Forge | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge` | | Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/service/log".Service` | ================================================ FILE: docs/docs/92-development/40-deprecations.md ================================================ # Deprecation Policy ## Pipeline Configuration Changes Pipeline configuration (YAML syntax) changes follow a strict deprecation process to ensure users have sufficient time to migrate. ### Process Timeline 1. **Minor Version N.x - Add Deprecation Warning** - Linter shows a warning (not an error) - Old syntax remains functional - Documentation is updated to reflect the new syntax - Warning message includes guidance on required changes 2. **Major Version (N+1).0 - Warning Becomes Error** - Linter issues an error (pipeline fails) - Old syntax is no longer supported - Breaking change is documented in the migration guide - Users **must** update their configurations 3. **Minor Version (N+1).x - Code Cleanup** - Deprecated code paths are removed - Implementation is simplified/refactored - Parser no longer recognizes the old syntax ### Example Old syntax: `secrets: [token]` New syntax: `environment: { TOKEN: { from_secret: token } }` - **v2.5.0:** Deprecation warning added in linter; both syntaxes work - **v2.6-2.9:** Warning persists; both syntaxes remain functional - **v3.0.0:** Linter error; old syntax fails (breaking change) - **v3.1.0:** Deprecated code paths removed; parser simplified ### Implementation Checklist When deprecating pipeline configuration syntax, ensure the following: - [ ] Add linter warning in `/pipeline/frontend/yaml/linter/` - [ ] Update JSON schema in `/pipeline/frontend/yaml/linter/schema` - [ ] Add test cases for deprecated syntax - [ ] Update documentation to reflect the new syntax ================================================ FILE: docs/docs/92-development/_category_.yaml ================================================ label: 'Development' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/docs/92-development/woodpecker-architecture.dot ================================================ digraph WoodpeckerArchitecture { graph [ rankdir=TB, splines=ortho, nodesep=0.5, ranksep=0.8, fontname="Helvetica" ] node [ shape=box, style="rounded,filled", fillcolor="#2b2b2b", fontcolor="white", fontname="Helvetica" ] edge [ color="#bdbdbd", arrowsize=0.7 ] /* ===================== UI ===================== */ subgraph cluster_ui { label="UI" fillcolor="#c7efe9" fontcolor="black" style="rounded,filled" ui_web [label="web/"] } /* ===================== SDK ===================== */ subgraph cluster_sdk { label="SDK (woodpecker-go)" fillcolor="#e8f5e9" fontcolor="black" style="rounded,filled" sdk [label="woodpecker-go"] } /* ===================== CLI ===================== */ subgraph cluster_cli { label="woodpecker-cli" fillcolor="#bfe9e0" fontcolor="black" style="rounded,filled" cli_cmd [label="cmd/cli/"] cli_core [label="cli/"] } /* ===================== Agent ===================== */ subgraph cluster_agent { label="woodpecker-agent" fillcolor="#ffe0c7" fontcolor="black" style="rounded,filled" agent_cmd [label="cmd/agent/"] agent_core [label="agent/"] } /* ===================== Pipelines ===================== */ subgraph cluster_pipelines { label="Pipelines" fillcolor="#ffe8d6" fontcolor="black" style="rounded,filled" pipe_core [label="pipeline/"] pipe_frontend [label="pipeline/frontend/\n(yaml)"] pipe_backend [label="pipeline/backend/\n(exec engines)"] } /* ===================== Server ===================== */ subgraph cluster_server { label="woodpecker-server" fillcolor="#dbe9ff" fontcolor="black" style="rounded,filled" srv_cmd [label="cmd/server/"] srv_router [label="server/router/"] srv_api [label="server/api/"] srv_grpc [label="server/rpc/"] srv_queue [label="server/queue/"] srv_pubsub [label="server/pubsub/"] srv_store [label="server/store/"] srv_model [label="server/model/"] srv_forge [label="server/forge/"] } /* ===================== Shared Libs ===================== */ subgraph cluster_shared { label="Shared Libs" fillcolor="#eeeeee" fontcolor="black" style="rounded,filled" shared_util [label="shared/util/"] shared_token [label="shared/token/"] shared_http [label="shared/httputil/"] shared_log [label="shared/logger/"] } /* ===================== External ===================== */ subgraph cluster_external { label="External Systems" style="rounded,dashed" fontcolor="white" ext_scm [label="SCM Providers", shape=cloud] ext_db [label="Database", shape=cylinder] } /* ===================== Runtime Interactions ===================== */ /* UI */ ui_web -> srv_router [xlabel="HTTP"] ui_web -> srv_api [xlabel="REST API"] /* CLI */ cli_cmd -> cli_core cli_core -> sdk sdk -> srv_api [xlabel="REST API"] /* Agent */ agent_cmd -> agent_core agent_core -> srv_grpc [xlabel="gRPC connect"] agent_core -> srv_queue [xlabel="poll work"] agent_core -> pipe_backend [xlabel="execute steps"] /* Pipelines */ pipe_frontend -> pipe_core pipe_core -> pipe_backend /* Server internal flow */ srv_cmd -> srv_router srv_router -> srv_api srv_api -> srv_store srv_api -> srv_pubsub srv_api -> srv_queue srv_grpc -> srv_queue srv_store -> srv_model /* External integrations */ srv_forge -> ext_scm [xlabel="SCM API"] srv_store -> ext_db [xlabel="SQL"] /* Shared libs usage (consumer -> library) */ srv_router -> shared_token srv_api -> shared_http srv_grpc -> shared_log pipe_core -> shared_util } ================================================ FILE: docs/docusaurus.config.ts ================================================ import * as path from 'path'; import type { VersionBanner, VersionOptions } from '@docusaurus/plugin-content-docs'; import type * as Preset from '@docusaurus/preset-classic'; import type { Config } from '@docusaurus/types'; import { themes } from 'prism-react-renderer'; import versions from './versions.json'; const docsVersions: { [version: string]: VersionOptions } = { current: { label: 'Next 🚧', banner: 'unreleased' as VersionBanner, }, }; const includeVersions = ['current', versions[0]]; versions.forEach((v, index) => { const version = { label: `${v}.x${index === 0 ? '' : ' 💀'}`, }; if (index !== 0 && process.env.NODE_ENV !== 'development') { version['banner'] = 'unmaintained'; includeVersions.push(v); } docsVersions[v] = version; }); const config = { title: 'Woodpecker CI', tagline: 'Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.', url: 'https://woodpecker-ci.org', baseUrl: '/', onBrokenLinks: 'throw', onBrokenAnchors: 'throw', onDuplicateRoutes: 'throw', organizationName: 'woodpecker-ci', projectName: 'woodpecker-ci.github.io', trailingSlash: false, headTags: [ { tagName: 'link', attributes: { href: 'https://floss.social/@WoodpeckerCI', rel: 'me', }, }, ], themeConfig: { navbar: { title: 'Woodpecker', logo: { alt: 'Woodpecker Logo', src: 'img/logo.svg', }, items: [ { type: 'doc', docId: 'intro/index', activeBaseRegex: 'docs/(?!migrations|awesome)', position: 'left', label: 'Docs', }, { to: '/plugins', position: 'left', label: 'Plugins', }, { to: 'blog', label: 'Blog', position: 'left' }, { label: 'More', position: 'left', items: [ { to: '/migrations', // Always point to newest migration guide activeBaseRegex: 'migrations', label: 'Migrations', }, { to: '/awesome', // Always point to newest awesome list activeBaseRegex: 'awesome', label: 'Awesome', }, { to: '/api', label: 'API', }, { to: '/about', label: 'About', }, ], }, { type: 'docsVersionDropdown', position: 'right', dropdownItemsAfter: [ { to: '/versions', label: 'All versions', }, ], }, { label: 'Sponsor Us', position: 'right', className: 'header-sponsor-link', href: 'https://opencollective.com/woodpecker-ci', }, { href: 'https://github.com/woodpecker-ci/woodpecker', position: 'right', className: 'header-github-link', 'aria-label': 'GitHub repository', }, ], }, footer: { style: 'dark', links: [ { title: 'Docs', items: [ { label: 'Welcome to Woodpecker', to: '/docs/intro', }, { label: 'Usage', to: '/docs/usage/intro', }, { label: 'Administration', to: '/docs/administration/general', }, { to: '/migrations', // Always point to newest migration guide activeBaseRegex: 'migrations', label: 'Migrations', }, { to: '/awesome', // Always point to newest awesome list activeBaseRegex: 'awesome', label: 'Awesome', }, { to: '/api', label: 'API', }, { to: '/about', label: 'About', }, ], }, { title: 'Community', items: [ { label: 'Matrix', href: 'https://matrix.to/#/#woodpecker:matrix.org', }, { label: 'Mastodon', href: 'https://floss.social/@WoodpeckerCI', }, { label: 'Bluesky', href: 'https://bsky.app/profile/woodpecker-ci.org', }, ], }, { title: 'More', items: [ { label: 'Translate', href: 'https://translate.woodpecker-ci.org/engage/woodpecker-ci/', }, { label: 'GitHub', href: 'https://github.com/woodpecker-ci/woodpecker', }, { href: 'https://ci.woodpecker-ci.org/repos/3780', label: 'CI', }, { href: 'https://opencollective.com/woodpecker-ci', label: 'Open Collective', }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} Woodpecker Authors. Built with Docusaurus.`, }, prism: { theme: themes.github, darkTheme: themes.dracula, additionalLanguages: [ 'diff', 'json', 'docker', 'javascript', 'css', 'bash', 'nginx', 'apacheconf', 'ini', 'nix', 'uri', // php is currently needed for redocusaurus // https://github.com/rohit-gohri/redocusaurus/issues/388 'php', ], }, announcementBar: { id: 'github-star', content: `If you like Woodpecker-CI, give us a star on GitHub ! ⭐️`, backgroundColor: 'var(--ifm-color-primary)', textColor: 'var(--ifm-color-gray-900)', }, tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 4, }, colorMode: { respectPrefersColorScheme: true, }, } satisfies Preset.ThemeConfig, plugins: [ () => ({ name: 'docusaurus-plugin-favicon', injectHtmlTags() { return { headTags: [ { tagName: 'link', attributes: { rel: 'icon', href: '/img/favicon.ico', sizes: 'any', }, }, { tagName: 'link', attributes: { rel: 'icon', href: '/img/favicon.svg', type: 'image/svg+xml', }, }, ], }; }, }), () => ({ name: 'webpack-config', configureWebpack() { return { devServer: { client: { webSocketURL: 'auto://0.0.0.0:0/ws', }, }, } as any; }, }), ], themes: [ path.resolve(__dirname, 'plugins', 'woodpecker-plugins', 'dist'), [ require.resolve('@easyops-cn/docusaurus-search-local'), { hashed: true, }, ], ], presets: [ [ '@docusaurus/preset-classic', { docs: { sidebarPath: require.resolve('./sidebars.js'), editUrl: 'https://github.com/woodpecker-ci/woodpecker/edit/main/docs/', includeCurrentVersion: true, lastVersion: versions[0], onlyIncludeVersions: includeVersions, versions: docsVersions, }, blog: { blogTitle: 'Blog', blogDescription: 'A blog for release announcements, turorials...', onInlineAuthors: 'ignore', }, theme: { customCss: require.resolve('./src/css/custom.css'), }, } satisfies Preset.Options, ], [ 'redocusaurus', { // Plugin Options for loading OpenAPI files specs: [ { spec: 'openapi.json', route: '/api/', }, ], // Theme Options for modifying how redoc renders them theme: { // Change with your site colors primaryColor: '#4caf50', }, }, ], ], markdown: { format: 'detect', hooks: { onBrokenMarkdownLinks: 'throw', onBrokenMarkdownImages: 'throw', }, }, future: { faster: true, v4: true, }, } satisfies Config; export default config; ================================================ FILE: docs/package.json ================================================ { "name": "woodpecker", "version": "0.0.0", "private": true, "packageManager": "pnpm@10.33.4", "scripts": { "start": "cd ../ && make generate-docs && cd docs && docusaurus start", "build": "pnpm build:woodpecker-plugins && docusaurus build", "build:woodpecker-plugins": "cd plugins/woodpecker-plugins && pnpm i && pnpm build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "format": "prettier --write .", "format:check": "prettier -c ." }, "dependencies": { "@docusaurus/core": "^3.9.2", "@docusaurus/faster": "^3.9.2", "@docusaurus/plugin-content-blog": "^3.9.2", "@docusaurus/preset-classic": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", "react": "^19.2.4", "react-dom": "^19.2.4", "redocusaurus": "^2.5.0" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/tsconfig": "3.10.1", "@docusaurus/types": "^3.9.2", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@types/node": "^25.3.3", "@types/react": "^19.2.14", "@types/react-helmet": "^6.1.11", "@types/react-router-dom": "^5.3.3", "prettier": "^3.8.1", "typescript": "^5.9.3" }, "pnpm": { "overrides": { "serialize-javascript": "^7.0.3", "follow-redirects": "^1.16.0", "uuid": "^14.0.0" } } } ================================================ FILE: docs/plugins/woodpecker-plugins/.gitignore ================================================ *.log .DS_Store node_modules dist ================================================ FILE: docs/plugins/woodpecker-plugins/package.json ================================================ { "name": "@woodpecker-ci/plugin-index", "version": "0.1.0", "private": true, "main": "dist/index.js", "typings": "dist/index.d.ts", "scripts": { "start": "pnpm run style && concurrently 'tsc -w' 'tsc -w -p tsconfig.jsx.json'", "build": "pnpm run style && tsc && tsc -p tsconfig.jsx.json", "style": "mkdir -p dist/theme/ && cp src/theme/style.css dist/theme/style.css" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/theme-classic": "^3.9.2", "@docusaurus/types": "^3.9.2", "@tsconfig/docusaurus": "^2.0.7", "@types/node": "^24.10.9", "axios": "^1.13.4", "concurrently": "^9.2.1", "isomorphic-dompurify": "^3.0.0", "marked": "^18.0.0", "slugify": "^1.6.6", "tslib": "^2.8.1", "typescript": "^5.9.3" }, "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" }, "dependencies": { "fuse.js": "^7.1.0", "yaml": "^2.8.2" } } ================================================ FILE: docs/plugins/woodpecker-plugins/plugins.json ================================================ { "plugins": [ { "name": "Clone plugin", "docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-git/main/docs.md", "verified": true }, { "name": "Docker Buildx", "docs": "https://codeberg.org/woodpecker-plugins/docker-buildx/raw/branch/main/docs.md", "verified": true }, { "name": "Codecov", "docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-codecov/master/docs.md", "verified": true }, { "name": "Surge preview", "docs": "https://codeberg.org/woodpecker-plugins/plugin-surge-preview/raw/branch/main/docs.md", "verified": true }, { "name": "S3 upload", "docs": "https://codeberg.org/woodpecker-plugins/plugin-s3/raw/branch/main/docs.md", "verified": true }, { "name": "Node PM", "docs": "https://codeberg.org/woodpecker-plugins/node-pm/raw/branch/main/docs.md", "verified": true }, { "name": "Prettier", "docs": "https://codeberg.org/woodpecker-plugins/prettier/raw/branch/main/docs.md", "verified": true }, { "name": "Extend env", "docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-extend-env/main/docs.md", "verified": true }, { "name": "Regex check", "docs": "https://codeberg.org/qwerty287/woodpecker-regex-check/raw/branch/main/docs.md", "verified": false }, { "name": "Gitea Create Pull Request", "docs": "https://codeberg.org/woodpecker-community/gitea-pull-request-create-plugin/raw/branch/main/docs.md", "verified": false }, { "name": "Gitea Pull comment", "docs": "https://raw.githubusercontent.com/markopolo123/gitea-comment-plugin/main/docs.md", "verified": false }, { "name": "Gitea publisher-golang", "docs": "https://raw.githubusercontent.com/woodpecker-kit/woodpecker-gitea-publisher-golang/main/doc/docs.md", "verified": false }, { "name": "Git Push", "docs": "https://raw.githubusercontent.com/appleboy/drone-git-push/master/DOCS.md", "verified": false }, { "name": "WebDAV", "docs": "https://raw.githubusercontent.com/ViViDboarder/drone-webdav/master/docs.md", "verified": false }, { "name": "Aptly publish", "docs": "https://gitea.zionetrix.net/bn8/aptly-publish/raw/branch/master/docs.md", "verified": false }, { "name": "Trigger", "docs": "https://codeberg.org/woodpecker-plugins/trigger/raw/branch/main/docs.md", "verified": true }, { "name": "Release", "docs": "https://codeberg.org/woodpecker-plugins/release/raw/branch/main/docs.md", "verified": true }, { "name": "Sccache", "docs": "https://seed.radicle.garden/raw/rad:zzmCBDptFLrfUEHSjcFsLJ2Aghav/head/docs.md", "verified": false }, { "name": "Woodpecker Email", "docs": "https://gitnet.fr/deblan/woodpecker-email/raw/branch/develop/DOCS.md", "verified": false }, { "name": "Woodpecker Feishu Bot", "docs": "https://github.com/wenerme/wode/raw/main/apps/woodpecker-feishu-bot/README.md", "verified": false }, { "name": "ntfy", "docs": "https://codeberg.org/l-x/woodpecker-ntfy/raw/branch/main/docs.md", "verified": false }, { "name": "Trivy", "docs": "https://codeberg.org/woodpecker-plugins/trivy/raw/branch/main/docs.md", "verified": true }, { "name": "MkDocs", "docs": "https://codeberg.org/woodpecker-plugins/mkdocs/raw/branch/main/docs.md", "verified": true }, { "name": "TODO-Checker", "docs": "https://codeberg.org/Epsilon_02/todo-checker/raw/branch/main/DOCS.md", "verified": false }, { "name": "Nextcloud Upload", "docs": "https://raw.githubusercontent.com/Ellpeck/WoodpeckerPlugins/main/nextcloud-upload/README.md", "verified": false }, { "name": "Kubernetes Deployment or StatefulSet Update", "docs": "https://raw.githubusercontent.com/euryecetelecom/woodpeckerci-kubernetes/master/README.md", "verified": false }, { "name": "Dockle", "docs": "https://raw.githubusercontent.com/euryecetelecom/woodpeckerci-dockle/master/README.md", "verified": false }, { "name": "NixOS Remote Builder", "docs": "https://codeberg.org/woodpecker-community/nix-remote-builder-plugin/raw/branch/main/docs.md", "verified": false }, { "name": "Release Helper", "docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-ready-release-go/main/docs.md", "verified": true }, { "name": "Nix - Attic", "docs": "https://git.vdx.hu/voidcontext/woodpecker-plugin-nix-attic/raw/branch/main/docs.md", "verified": false }, { "name": "Codeberg Pages Deploy", "docs": "https://codeberg.org/sugar700/plugin-codeberg-pages-deploy/raw/branch/master/README.md", "verified": false }, { "name": "Reviewdog golangci-lint", "docs": "https://codeberg.org/woodpecker-plugins/reviewdog-golangci-lint/raw/branch/main/docs.md", "verified": true }, { "name": "Reviewdog ESLint", "docs": "https://codeberg.org/woodpecker-plugins/reviewdog-eslint/raw/branch/main/docs.md", "verified": true }, { "name": "Ansible", "docs": "https://codeberg.org/woodpecker-plugins/ansible/raw/branch/main/docs.md", "verified": true }, { "name": "Kaniko", "docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-kaniko/main/docs.md", "verified": true }, { "name": "Gradle Wrapper Validation", "docs": "https://codeberg.org/beaks/gradle-wrapper-validation/raw/branch/main/docs.md", "verified": false }, { "name": "Sonatype Nexus", "docs": "https://raw.githubusercontent.com/rockdrilla/woodpecker-sonatype-nexus/main/docs.md", "verified": false }, { "name": "Mastodon Post", "docs": "https://codeberg.org/woodpecker-plugins/mastodon-post/raw/branch/main/docs.md", "verified": true }, { "name": "Bluesky Post", "docs": "https://codeberg.org/woodpecker-plugins/bluesky-post/raw/branch/main/docs.md", "verified": true }, { "name": "Discord", "docs": "https://raw.githubusercontent.com/appleboy/drone-discord/master/DOCS.md", "verified": false }, { "name": "Forge deployments", "docs": "https://raw.githubusercontent.com/woodpecker-ci/plugin-deployments/main/docs.md", "verified": true }, { "name": "Twine", "docs": "https://gitea.elara.ws/music-kraken/plugin-twine/raw/branch/master/docs.md", "verified": false }, { "name": "Gitea Package", "docs": "https://codeberg.org/woodpecker-community/gitea-package/raw/branch/main/docs.md", "verified": false }, { "name": "Is It Up Yet?", "docs": "https://raw.githubusercontent.com/dvjn/woodpecker-is-it-up-yet-plugin/main/docs.md", "verified": false }, { "name": "Docker Tags", "docs": "https://raw.githubusercontent.com/dvjn/woodpecker-docker-tags-plugin/main/docs.md", "verified": false }, { "name": "SSH SCP", "docs": "https://raw.githubusercontent.com/appleboy/drone-scp/refs/heads/master/DOCS.md", "verified": false }, { "name": "Telegram", "docs": "https://raw.githubusercontent.com/appleboy/drone-telegram/refs/heads/master/DOCS.md", "verified": false }, { "name": "EditorConfig Checker", "docs": "https://codeberg.org/woodpecker-plugins/editorconfig-checker/raw/branch/main/docs.md", "verified": true }, { "name": "Microsoft Teams Notify", "docs": "https://raw.githubusercontent.com/GECO-IT/woodpecker-plugin-teams-notify/refs/heads/main/docs.md", "verified": false }, { "name": "Basic Git Changelog", "docs": "https://raw.githubusercontent.com/GECO-IT/woodpecker-plugin-git-basic-changelog/refs/heads/main/docs.md", "verified": false }, { "name": "Hugo", "docs": "https://raw.githubusercontent.com/maurerle/woodpecker-hugo/refs/heads/main/docs.md", "verified": false }, { "name": "Home Assistant Notify", "docs": "https://raw.githubusercontent.com/DHandspikerWade/woodpecker-plugin-ha-notify/refs/heads/main/docs.md", "verified": false }, { "name": "Microsoft Teams Notification (Advanced)", "docs": "https://raw.githubusercontent.com/mobydeck/ci-teams-notification/refs/heads/main/docs.md", "verified": false }, { "name": "Pre-commit Runner", "docs": "https://codeberg.org/sp1thas/woodpecker-ci-pre-commit/raw/branch/main/docs.md", "verified": false }, { "name": "Portainer Service Update", "docs": "https://codeberg.org/woodpecker-community/portainer-service-update/raw/branch/main/docs.md", "verified": false }, { "name": "Peckify", "docs": "https://codeberg.org/woodpecker-community/peckify/raw/branch/main/docs.md", "verified": false }, { "name": "ASCII JUnit Test Report", "docs": "https://raw.githubusercontent.com/brainbaking/woodpecker-ascii-junit/refs/heads/main/README.md", "verified": false }, { "name": "SonarQube", "docs": "https://raw.githubusercontent.com/j04n-f/woodpecker-sonar-plugin/refs/heads/main/docs.md", "verified": false }, { "name": "Github App Tokens", "docs": "https://raw.githubusercontent.com/yyewolf/woodpecker-plugins/refs/heads/main/github-app-token/docs.md", "verified": false }, { "name": "Github Comment", "docs": "https://raw.githubusercontent.com/yyewolf/woodpecker-plugins/refs/heads/main/github-comment/docs.md", "verified": false }, { "name": "BunnyCDN Cache Purge", "docs": "https://codeberg.org/bentasker/woodpecker-ci-bunnycdn-cache-flush/raw/branch/main/docs.md", "verified": false }, { "name": "Buildah", "docs": "https://raw.githubusercontent.com/404systems/plugin-buildah/refs/heads/main/docs.md", "verified": false }, { "name": "Opengrep", "docs": "https://raw.githubusercontent.com/KalvadTech/woodpecker-opengrep/refs/heads/main/docs.md", "verified": false }, { "name": "AgentScan", "docs": "https://codeberg.org/woodpecker-plugins/agentscan/raw/branch/main/docs.md", "verified": true }, { "name": "S3 Cache", "docs": "https://codeberg.org/landre/woodpecker-plugins/raw/branch/main/cache/docs.md", "verified": false }, { "name": "Laravel Forge", "docs": "https://raw.githubusercontent.com/njaaazi/laravel-forge-woodpecker/main/docs.md", "verified": false } ] } ================================================ FILE: docs/plugins/woodpecker-plugins/src/index.ts ================================================ import fs from 'fs'; import path from 'path'; import { LoadContext, Plugin, PluginContentLoadedActions } from '@docusaurus/types'; import axios, { AxiosError } from 'axios'; import slugify from 'slugify'; import * as markdown from './markdown'; import { Content, WoodpeckerPlugin, WoodpeckerPluginHeader, WoodpeckerPluginIndexEntry } from './types'; async function loadContent(): Promise { const file = path.join(__dirname, '..', 'plugins.json'); const pluginsIndex = JSON.parse(fs.readFileSync(file).toString()) as { plugins: WoodpeckerPluginIndexEntry[] }; const plugins = ( await Promise.all( pluginsIndex.plugins.map(async (i): Promise => { let docsContent: string; try { const response = await axios(i.docs); docsContent = response.data; } catch (e) { const axiosError = e as AxiosError; console.error( "Can't fetch docs file", i.docs, axiosError.message, axiosError.response?.status, axiosError.response?.statusText, ); return undefined; } let docsHeader: WoodpeckerPluginHeader; try { docsHeader = markdown.getHeader(docsContent); } catch (e) { console.error("Can't get header from docs file", i.docs, (e as Error).message); return undefined; } const docsBody = await markdown.getContent(docsContent); if (!docsHeader.name) { return undefined; } let pluginIconDataUrl: string | undefined; if (docsHeader.icon) { try { const response = await axios(docsHeader.icon, { responseType: 'arraybuffer', }); const imgType = response.headers['content-type']; if (imgType) { pluginIconDataUrl = `data:${imgType.toString()};base64,${Buffer.from(response.data, 'binary').toString( 'base64', )}`; } } catch (e) { console.error("Can't fetch plugin icon", docsHeader.icon, (e as AxiosError).message); } } return { name: docsHeader.name, slug: slugify(docsHeader.name, { lower: true, strict: true }), url: docsHeader.url, icon: docsHeader.icon, description: docsHeader.description, docs: docsBody, tags: docsHeader.tags || [], author: docsHeader.author, containerImage: docsHeader.containerImage, containerImageUrl: docsHeader.containerImageUrl, verified: i.verified || false, iconDataUrl: pluginIconDataUrl, } satisfies WoodpeckerPlugin; }), ) ).filter((plugin): plugin is WoodpeckerPlugin => plugin !== undefined); return { plugins, }; } async function contentLoaded({ content: { plugins }, actions, }: { content: Content; actions: PluginContentLoadedActions; }): Promise { const { createData, addRoute } = actions; const pluginsJsonPath = await createData('plugins.json', JSON.stringify(plugins)); await Promise.all( plugins.map(async (plugin, i) => { const pluginJsonPath = await createData(`plugin-${i}.json`, JSON.stringify(plugin)); addRoute({ path: `/plugins/${plugin.slug}`, component: '@theme/WoodpeckerPlugin', modules: { plugin: pluginJsonPath, }, exact: true, }); }), ); addRoute({ path: '/plugins', component: '@theme/WoodpeckerPluginList', modules: { plugins: pluginsJsonPath, }, exact: true, }); } export default function pluginWoodpeckerPluginsIndex(context: LoadContext, options: any): Plugin { return { name: 'woodpecker-plugins', loadContent, contentLoaded, getThemePath() { return path.join(__dirname, '..', 'dist', 'theme'); }, getTypeScriptThemePath() { return path.join(__dirname, '..', 'src', 'theme'); }, getPathsToWatch() { return [path.join(__dirname, '..', 'dist', '**', '*.{js,jsx,css}')]; }, }; } const getSwizzleComponentList = (): string[] => { return ['WoodpeckerPluginList', 'WoodpeckerPlugin']; }; export { getSwizzleComponentList }; ================================================ FILE: docs/plugins/woodpecker-plugins/src/markdown.ts ================================================ import DOMPurify from 'isomorphic-dompurify'; import { parse as YAMLParse } from 'yaml'; const tokens = ['---', '---']; const regexHeader = new RegExp('^' + tokens[0] + '([\\s|\\S]*?)' + tokens[1]); const regexContent = new RegExp('^ *?\\' + tokens[0] + '[^]*?' + tokens[1] + '*'); export function getHeader(data: string): T { const header = getRawHeader(data); return YAMLParse(header) as T; } export function getRawHeader(data: string): string { const header = regexHeader.exec(data); if (!header) { throw new Error("Can't get the header"); } return header[1]; } export async function getContent(data: string): Promise { const marked = await import('marked'); const content = data.replace(regexContent, '').replace(//gm, ''); if (!content) { throw new Error("Can't get the content"); } return DOMPurify.sanitize(marked.marked(content) as string); } ================================================ FILE: docs/plugins/woodpecker-plugins/src/theme/Icons.tsx ================================================ import React from 'react'; export const IconVerified = (size = 32) => (
); export const IconContainer = (size = 32) => (
); export const IconWebsite = (size = 32) => ( ); export const IconPlugin = (maxWidth = 50) => ( ); ================================================ FILE: docs/plugins/woodpecker-plugins/src/theme/WoodpeckerPlugin.tsx ================================================ import Layout from '@theme/Layout'; import React from 'react'; import { WoodpeckerPlugin as WoodpeckerPluginType } from '../types'; import { IconContainer, IconPlugin, IconVerified, IconWebsite } from './Icons'; export function WoodpeckerPlugin({ plugin }: { plugin: WoodpeckerPluginType }) { return (
Plugins / {plugin.name}

{plugin.name}

{plugin.verified && IconVerified()}
{plugin.author && by {plugin.author}}
{plugin.containerImage && (
{IconContainer(20)} {plugin.containerImageUrl ? ( {plugin.containerImage} ) : ( {plugin.containerImage} )}
)} {plugin.url && (
{IconWebsite(20)}
Website
)} {plugin.tags && (
{plugin.tags.map((tag, idx) => ( {tag} ))}
)}

{plugin.description}

{plugin.iconDataUrl ? : IconPlugin(150)}

); } export default WoodpeckerPlugin; ================================================ FILE: docs/plugins/woodpecker-plugins/src/theme/WoodpeckerPluginList.tsx ================================================ import Layout from '@theme/Layout'; import Fuse from 'fuse.js'; import React, { useRef, useState } from 'react'; import './style.css'; import { WoodpeckerPlugin } from '../types'; import { IconPlugin, IconVerified } from './Icons'; function PluginPanel({ plugin }: { plugin: WoodpeckerPlugin }) { const pluginUrl = `/plugins/${plugin.slug}`; return (
{plugin.iconDataUrl ? : IconPlugin()}

{plugin.name}

{plugin.description}

{plugin.tags && (
{plugin.tags.map((tag, idx) => ( {tag} ))}
)}
{plugin.verified &&
{IconVerified()}
}
); } export function WoodpeckerPluginList({ plugins }: { plugins: WoodpeckerPlugin[] }) { const applyForIndexUrl = 'https://github.com/woodpecker-ci/woodpecker/edit/main/docs/plugins/woodpecker-plugins/plugins.json'; const [query, setQuery] = useState(''); const fuse = useRef( new Fuse(plugins, { keys: ['name', 'description', 'tags'], threshold: 0.3, }), ); const searchedPlugins = query.length >= 1 ? fuse.current.search(query).map((p) => p.item) : plugins; return (

Woodpecker CI plugins

This list contains plugins which you can use to easily execute usual pipeline tasks.

🎉 Add your plugin
setQuery(event.currentTarget.value)} placeholder="Search for a plugin ..." className="wp-plugin-search" />
{searchedPlugins.map((plugin) => ( ))}
); } export default WoodpeckerPluginList; ================================================ FILE: docs/plugins/woodpecker-plugins/src/theme/style.css ================================================ .wp-plugins-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); grid-gap: 2rem; margin-top: 2rem; } @media screen and (max-width: 450px) { .wp-plugins-list { grid-template-columns: auto; } } .wp-plugin-card { display: flex; position: relative; max-width: 32rem; color: var(--ifm-navbar-link-color); text-decoration: none; padding: 0.5rem 0 1rem; flex-grow: 1; margin: 0 auto; width: 100%; } .wp-plugin-card:hover { color: var(--ifm-navbar-link-color); text-decoration: none; } .wp-plugin-card:hover h3 { color: var(--ifm-link-color); text-decoration: underline; } .wp-plugin-card h3 { color: var(--ifm-link-color); } .wp-plugin-verified { position: absolute; top: 0.75rem; right: 1rem; color: #0369a1; } .wp-plugin-tags { display: flex; gap: 0.5rem; flex-wrap: wrap; } .wp-plugin-search { width: 100%; max-width: 32rem; margin: 0 auto; padding: 1rem 1rem 1rem 2.25rem; font-size: 1.1rem; appearance: none; background: var(--ifm-navbar-search-input-background-color) var(--ifm-navbar-search-input-icon) no-repeat 0.75rem 1rem / 1.1rem 1.1rem; border-radius: 0.5rem; border: 1px solid var(--ifm-card-background-color); color: var(--ifm-navbar-search-input-color); } .wp-plugin-search::placeholder { color: var(--ifm-navbar-search-input-color); } .wp-plugin-breadcrumbs { margin-bottom: 2rem; } ================================================ FILE: docs/plugins/woodpecker-plugins/src/types.ts ================================================ export type WoodpeckerPluginHeader = { name?: string; // name of the plugin description?: string; // short description of the plugin url?: string; // url of the plugin normally link to forge tags?: string[]; // tags to categorize the plugin author?: string; // author of the plugin icon?: string; // url pointing to an icon containerImage?: string; // name of a container image containerImageUrl?: string; // url to a container image registry }; export type WoodpeckerPluginIndexEntry = { name: string; // name of the plugin docs: string; // http url to the docs.md file verified?: boolean; // plugins maintained by trusted parties }; export type WoodpeckerPlugin = WoodpeckerPluginHeader & { name: string; slug: string; docs: string; // body of the docs .md file verified: boolean; // we set verified to false when not explicitly set iconDataUrl?: string; }; export type Content = { plugins: WoodpeckerPlugin[]; }; ================================================ FILE: docs/plugins/woodpecker-plugins/tsconfig.json ================================================ { "extends": "@tsconfig/docusaurus/tsconfig.json", "include": ["src", "types"], "exclude": ["node_modules", "**/__tests__/**/*", "**/dist/**/*", "src/theme"], "compilerOptions": { "declaration": false, "declarationMap": false, "esModuleInterop": true, "importHelpers": true, "moduleResolution": "Node16", "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": false, "strict": true, "module": "Node16", "target": "ES6", "outDir": "dist", "baseUrl": ".", "rootDir": "src", "pretty": true, "noEmit": false } } ================================================ FILE: docs/plugins/woodpecker-plugins/tsconfig.jsx.json ================================================ { "extends": "./tsconfig.json", "include": ["src/theme"], "exclude": ["node_modules", "**/__tests__/**/*", "**/dist/**/*"], "compilerOptions": { "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "isolatedModules": true, "module": "esnext", "jsx": "preserve", "strict": false } } ================================================ FILE: docs/pnpm-workspace.yaml ================================================ packages: - '.' - 'plugins/**' overrides: webpack-dev-server@<=5.2.0: '>=5.2.1' ================================================ FILE: docs/sidebars.js ================================================ module.exports = { // let Docusaurus generates a sidebar from the docs folder structure tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }], }; ================================================ FILE: docs/src/components/HomepageFeatures.js ================================================ import clsx from 'clsx'; import React from 'react'; import styles from './HomepageFeatures.module.css'; const FeatureList = [ { title: 'OpenSource and free', Svg: require('../../static/img/feat-opensource.svg').default, description: ( <> Woodpecker is and always will be totally free. As Woodpecker's{' '} source code {' '} is open-source you can contribute to help evolving the project. ), }, { title: 'Based on docker containers', Svg: require('../../static/img/feat-docker.svg').default, description: ( <> Woodpecker uses docker containers to execute pipeline steps. If you need more than a normal docker image, you can create plugins to extend the pipeline features.{' '} How do plugins work? ), }, { title: 'Multi workflows', Svg: require('../../static/img/workflows.svg').default, description: ( <> Woodpecker allows you to easily create multiple workflows for your project. They can even depend on each other. Check out the docs ), }, ]; function Feature({ Svg, title, description }) { return (

{title}

{description}

); } export default function HomepageFeatures() { return (
{FeatureList.map((props, idx) => ( ))}
); } ================================================ FILE: docs/src/components/HomepageFeatures.module.css ================================================ .features { display: flex; align-items: center; padding: 2rem 0; width: 100%; } .featureSvg { height: 200px; width: 200px; } ================================================ FILE: docs/src/css/custom.css ================================================ /** * Any CSS included here will be global. The classic template * bundles Infima by default. Infima is a CSS framework designed to * work well for content-centric websites. */ /* You can override the default Infima variables here. */ :root { --ifm-color-primary: #369943; --ifm-code-font-size: 95%; } .docusaurus-highlight-code-line { background-color: rgba(0, 0, 0, 0.1); display: block; margin: 0 calc(-1 * var(--ifm-pre-padding)); padding: 0 var(--ifm-pre-padding); } html[data-theme='dark'] .docusaurus-highlight-code-line { background-color: rgba(0, 0, 0, 0.3); } .header-github-link:hover { opacity: 0.6; } .header-github-link:before { content: ''; width: 24px; height: 24px; display: flex; background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat; } html[data-theme='dark'] .header-github-link:before { background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='white' d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat; } .header-sponsor-link { white-space: nowrap; display: flex; align-items: center; gap: 6px; } .header-sponsor-link::before { content: ''; width: 24px; height: 24px; background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'%3E%3Cpath fill='%23369943' d='M26 9.312c0-4.391-2.969-5.313-5.469-5.313-2.328 0-4.953 2.516-5.766 3.484-0.375 0.453-1.156 0.453-1.531 0-0.812-0.969-3.437-3.484-5.766-3.484-2.5 0-5.469 0.922-5.469 5.313 0 2.859 2.891 5.516 2.922 5.547l9.078 8.75 9.063-8.734c0.047-0.047 2.938-2.703 2.938-5.563zM28 9.312c0 3.75-3.437 6.891-3.578 7.031l-9.734 9.375c-0.187 0.187-0.438 0.281-0.688 0.281s-0.5-0.094-0.688-0.281l-9.75-9.406c-0.125-0.109-3.563-3.25-3.563-7 0-4.578 2.797-7.313 7.469-7.313 2.734 0 5.297 2.156 6.531 3.375 1.234-1.219 3.797-3.375 6.531-3.375 4.672 0 7.469 2.734 7.469 7.313z'/%3E%3C/svg%3E") no-repeat; } ================================================ FILE: docs/src/pages/about.md ================================================ # About Woodpecker has been originally forked from Drone 0.8 as the Drone CI license was changed after the 0.8 release from Apache 2.0 to a proprietary license. Woodpecker is based on this latest freely available version. ## History Woodpecker was originally forked by [@laszlocph](https://github.com/laszlocph) in 2019. A few important time points: - [`2fbaa56`](https://github.com/woodpecker-ci/woodpecker/commit/2fbaa56eee0f4be7a3ca4be03dbd00c1bf5d1274) is the first commit of the fork, made on Apr 3, 2019. - The first release [v0.8.91](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.91) was published on Apr 6, 2019. - On Aug 27, 2019, the project was renamed to "Woodpecker" ([`630c383`](https://github.com/woodpecker-ci/woodpecker/commit/630c383181b10c4ec375e500c812c4b76b3c52b8)). - The first release under the name "Woodpecker" was published on Sep 9, 2019 ([v0.8.104](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.104)). ## Differences to Drone Woodpecker is a community-focused software that will stay free and open source forever, while Drone is managed by [Harness](https://harness.io/) and published under [Polyform Small Business](https://polyformproject.org/licenses/small-business/1.0.0/) license. ================================================ FILE: docs/src/pages/awesome.md ================================================ # Awesome Woodpecker A curated list of assets (tools, projects, blog posts) related to Woodpecker CI. If you want to add a new entry, open a [pull-request](https://github.com/woodpecker-ci/woodpecker/edit/main/docs/docs/92-awesome.md). ## Official Resources - [Woodpecker CI pipeline configs](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) - Complex setup containing different kind of pipelines - [Golang tests](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/test.yaml) - [Typescript, eslint & Vue](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/web.yaml) - [Docusaurus & publishing to GitHub Pages](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docs.yaml) - [Docker container building](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docker.yaml) ## Projects using Woodpecker - [Woodpecker CI](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) itself - [All official plugins](https://github.com/woodpecker-ci?q=plugin&type=all) - [dessalines/thumb-key](https://github.com/dessalines/thumb-key/blob/main/.woodpecker.yml) - Android Jetpack compose linting and building - [Vieter](https://git.rustybever.be/vieter-v/vieter) - Archlinux/Pacman repository server & automated package build system - [Rieter](https://git.rustybever.be/Chewing_Bever/rieter) - Rewrite of the Vieter project in Rust - [Alex](https://git.rustybever.be/Chewing_Bever/alex) - Minecraft server wrapper designed to automate backups & complement Docker installations ## Tools - [Convert Drone CI pipelines to Woodpecker CI](https://codeberg.org/lafriks/woodpecker-pipeline-transform) - [Ansible NAS](https://github.com/davestephens/ansible-nas/) - a homelab Ansible playbook that can set up Woodpecker CI and Gitea - [picus](https://github.com/windsource/picus) - Picus connects to a Woodpecker CI server and creates an agent in the cloud when there are pending workflows. - [Hetzner cloud](https://www.hetzner.com/cloud) based [Woodpecker compatible autoscaler](https://git.ljoonal.xyz/ljoonal/hetzner-ci-autoscaler) - Creates and destroys VPS instances based on the count of pending & running jobs. - [woodpecker-lint](https://git.schmidl.dev/schtobia/woodpecker-lint) - A repository for linting a Woodpecker config file via pre-commit hook - [woodpecker-shellcheck](https://codeberg.org/rfinnie/woodpecker-shellcheck) - A pre-commit hook which runs workflow steps' commands through [shellcheck](https://www.shellcheck.net/) lint. - [Grafana Dashboard](https://github.com/Janik-Haag/woodpecker-grafana-dashboard) - A dashboard visualizing information exposed by the Woodpecker prometheus endpoint. - [woodpecker-autoscaler](https://github.com/Lerentis/woodpecker-autoscaler) - Yet another Woodpecker autoscaler currently targeting [Hetzner cloud](https://www.hetzner.com/cloud) that works in parallel to other autoscaler implementations. - [Woodpecker MCP](https://github.com/j04n-f/woodpecker-mcp) - A Model Context Protocol (MCP) server that connects AI assistants to Woodpecker CI. Debug pipeline failures, analyze build logs, and troubleshoot CI/CD configurations with AI assistance. ## Configuration Services - [Dynamic Pipelines for Nix Flakes](https://github.com/pinpox/woodpecker-flake-pipeliner) - Define pipelines as Nix Flake outputs ## Pipelines - [Collection of pipeline examples](https://codeberg.org/Codeberg-CI/examples) ## Posts & tutorials - [Step-by-step guide to modern, secure and Open-source CI setup](https://devforth.io/blog/step-by-step-guide-to-modern-secure-ci-setup/) - [Using Woodpecker CI for my static sites](https://jan.wildeboer.net/2022/07/Woodpecker-CI-Jekyll/) - [Woodpecker CI @ Codeberg](https://www.sarkasti.eu/articles/post/woodpecker/) - [Deploy Docker/Compose using Woodpecker CI](https://hinty.io/vverenko/deploy-docker-compose-using-woodpecker-ci/) - [Installing Woodpecker CI in your personal homelab](https://pwa.io/articles/installing-woodpecker-in-your-homelab/) - [Locally Cached Nix CI with Woodpecker](https://blog.kotatsu.dev/posts/2023-04-21-woodpecker-nix-caching/) - [How to run Cypress auto-tests on Woodpecker CI and report results to Slack](https://devforth.io/blog/how-to-run-cypress-auto-tests-on-woodpecker-ci-and-report-results-to-slack/) - [Quest For CICD - WoodpeckerCI](https://omaramin.me/posts/woodpecker/) - [Installing gitea and woodpecker using binary packages](https://neelex.com/2023/03/26/Installing-gitea-using-binary-packages/) - [Deploying mdbook to codeberg pages using Woodpecker CI](https://www.markpitblado.me/blog/deploying-mdbook-to-codeberg-pages-using-woodpecker-ci/) - [Deploy a Fly app with Woodpecker CI](https://joeroe.io/2024/01/09/deploy-fly-woodpecker-ci.html) - [Ansible - using Woodpecker as an alternative to Semaphore](https://pat-s.me/ansible-using-woodpecker-as-an-alternative-to-semaphore/) - [Simple selfhosted CI/CD with Woodpecker](https://xyquadrat.ch/blog/simple-ci-with-woodpecker/) - [Notes to self on Woodpecker-CI](https://jpmens.net/2023/09/22/notes-to-self-on-woodpecker-ci/) - [CI/CD with Woodpecker and Gitea](https://wilw.dev/blog/2023/04/23/woodpecker-ci/) - [Dookerized deploy setup using Woodpecker CI and Harbor registry](https://devforth.io/blog/dookerized-deploy-setup-using-woodpecker-ci-and-harbor-registry/) - [Woodpecker Shenanigans](https://jan.wildeboer.net/2024/12/Woodpecker-Shenanigans/) - [Ansible - using Woodpecker as an alternative to Semaphore](https://pat-s.me/ansible-using-woodpecker-as-an-alternative-to-semaphore/) - [Building a blog using Hugo, MinIO, and Woodpecker CI](https://bluemedia.dev/blog/blog-using-hugo-minio-and-woodpcker-ci/) - [Woodpecker CI](https://blog.mariom.pl/posts/2023/03/woodpecker/) - [Deploy Gitea and Woodpecker CI with Docker Compose](https://www.alexruf.net/posts/deploy-gitea-woodpecker-docker-compose/) - [Improving Multi-Arch Image Build Performance by not Emulating](https://blog.mei-home.net/posts/improving-container-image-build-perf-with-buildah/) - [CI pipelines with Woodpecker](https://blog.reinhard.codes/2024/11/19/ci-pipelines-with-woodpecker/) - [Setting up Woodpecker CI at home](https://jamesbrechtel.com/posts/wasting-time-for-misery-and-loss/) - [Woodpecker CI with automatic runner creation](https://planet.kde.org/jonah-bruchert-2023-05-13-woodpecker-ci-with-automatic-runner-creation/) - [Self-Hosted CI: Install and Run Woodpecker CI on Your VPS](https://mangohost.net/blog/self-hosted-ci-install-and-run-woodpecker-ci-on-your-vps/) - [Automating My Blog With Gitea and Woodpecker](https://bgenc.net/2022.11.19.automating-my-blog-with-gitea-and-woodpecker/) - [Testcontainers in Woodpecker CI](https://gaborpihaj.com/posts/testcontainers-in-woodpecker-ci/) ## Videos - [Replace Ansible Semaphore with Woodpecker CI](https://www.youtube.com/watch?v=d610YPvCB0E) - ["unexpected EOF" error when trying to pair Woodpecker CI served through the Caddy with Gitea](https://www.youtube.com/watch?v=n7Hyvt71Np0) - [CICD Environment in Docker Swarm behind Caddy Server - Part 2 Woodpeckerci](https://www.youtube.com/watch?v=rkbw_k7JvS0) - [How to Build & Publish Custom Docker Container using Gitea & Woodpecker behind Caddy Server | TUNEIT](https://www.youtube.com/watch?v=9m7DbgL1mNk) - [Radicle Woodpecker CI Integration](https://www.youtube.com/watch?v=Ks1nbYLn4P8) - [woodpecker-ci/woodpecker - Gource visualisation](https://www.youtube.com/watch?v=38JuakZ6m5s) - [Woodpecker CI](https://www.youtube.com/watch?v=Htd98Mepu4s) ## Plugins We have a separate [index](/plugins) for plugins. ================================================ FILE: docs/src/pages/index.module.css ================================================ /** * CSS files with the .module.css suffix will be treated as CSS modules * and scoped locally. */ .heroBanner { padding: 4rem 0; text-align: center; position: relative; overflow: hidden; } @media screen and (max-width: 966px) { .heroBanner { padding: 2rem; } } .buttons { display: flex; align-items: center; justify-content: center; } ================================================ FILE: docs/src/pages/index.tsx ================================================ import Link from '@docusaurus/Link'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import Layout from '@theme/Layout'; import clsx from 'clsx'; import React from 'react'; import HomepageFeatures from '../components/HomepageFeatures'; import styles from './index.module.css'; function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); return (

{siteConfig.title}

{siteConfig.tagline}

Woodpecker Tutorial - 5min ⏱️
); } export default function Home() { const { siteConfig } = useDocusaurusContext(); return (
); } ================================================ FILE: docs/src/pages/migrations.md ================================================ # Migrations To enhance the usability of Woodpecker and meet evolving security standards, occasional migrations are necessary. While we aim to minimize these changes, some are unavoidable. If you experience significant issues during a migration to a new version, please let us know so maintainers can reassess the updates. ## `next` ### User-facing migrations - (Kubernetes) Deprecated `step` label on pod in favor of new namespaced label `woodpecker-ci.org/step`. The `step` label will be removed in a future update. - deprecated `CI_COMMIT_AUTHOR_AVATAR` and `CI_PREV_COMMIT_AUTHOR_AVATAR` env vars in favor of `CI_PIPELINE_AVATAR` and `CI_PREV_PIPELINE_AVATAR` - deprecated `runs_on` workflow property in favor of `when.status`. ### Admin-facing migrations - changed env var `WOODPECKER_CONFIG_SERVICE_ENDPOINT` to `WOODPECKER_CONFIG_EXTENSION_ENDPOINT` #### Extensions Extension HTTP calls (as of now the configuration extension) will by default only be allowed to contact external hosts. Set `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` accordingly to allow additional hosts as needed. ### API changes - The pipeline model has been changed to use nested objects grouped based on the event (e.g. instead of a generic `title` it now uses `pr.title`). Following properties are deprecated and should be replaced by the their new counterparts: - `sender` => - `cron` for cron events ### Internal changes - Renamed the server flag `config-service-endpoint` to `config-extension-endpoint` ## 3.0.0 ### User-facing migrations #### Workflow syntax changes - `secrets` have been entirely removed in favor of `environment` combined with the `from_secret` syntax ([#4363](https://github.com/woodpecker-ci/woodpecker/pull/4363)). As `secrets` are just normal env vars which are masked, the goal was to allow them to be declared next to normal env vars and at the same time reduce the keyword syntax count. Additionally, the `from_secret` syntax gives more flexibility in naming. Whereas beforehand `secrets` where always named after their initial secret name, the `from_secret` reference can now be different. Last, one can inject multiple different env vars from the same secret reference. 2.x: ```yaml secrets: [my_token] ``` 3.x: ```yaml environment: MY_TOKEN: from_secret: my_token ``` Learn more about using [secrets](https://woodpecker-ci.org/docs/next/usage/secrets#usage) - The `includes` and `excludes` event filter options have been removed - Previously, env vars have been automatically sanitized to uppercase. As this has been confusing, the type-case of the secret definition is now respected ([#3375](https://github.com/woodpecker-ci/woodpecker/pull/3375)). - The `environment` filter option has been removed in favor of `when.evaluate` - Grouping of steps via `steps.[name].group` should now be done using `steps.[name].depends_on` #### Environment variables - Environment variables must now be defined as maps. List definitions are disallowed. ([#4016](https://github.com/woodpecker-ci/woodpecker/pull/4016)) 2.x: ```yaml environment: - ENV1=value1 ``` 3.x: ```yaml environment: ENV1: value1 ``` The following built-in environment variables have been removed/replaced: - `CI_COMMIT_URL` has been deprecated in favor of `CI_PIPELINE_FORGE_URL` - `CI_STEP_FINISHED` as it was empty during execution - `CI_PIPELINE_FINISHED` as it was empty during execution - `CI_PIPELINE_STATUS` due to always being set to `success` - `CI_STEP_STATUS` due to always being set to `success` - `WOODPECKER_WEBHOOK_HOST` in favor of `WOODPECKER_EXPERT_WEBHOOK_HOST` Environment variables which are empty after workflow parsing are not being injected into the build but filtered out beforehand ([#4193](https://github.com/woodpecker-ci/woodpecker/pull/4193)) #### Security - The "gated" option, which restricted which pipelines can start right away without requiring approval, has been replaced by "require-approval" option. Even though this feature ([#3348](https://github.com/woodpecker-ci/woodpecker/pull/3348)) was backported to 2.8, no default is explicitly set. The new default in 3.0 is to require approval only for forked repositories. This allows easier management of dependency bots and other trusted entities having write access to the repository. #### Former deprecations The following syntax deprecations will now result in an error: - `pipeline:` ([#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916)) - `platform:` ([#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916)) - `branches:` ([#3916](https://github.com/woodpecker-ci/woodpecker/pull/3916)) #### CLI changes The following restructuring was done to achieve a more consistent grouping: | Old Command | New Command | | ------------------------------------------- | ------------------------------------------- | | `woodpecker-cli registry` | `woodpecker-cli repo registry` | | `woodpecker-cli secret --global` | `woodpecker-cli admin secret` | | `woodpecker-cli user` | `woodpecker-cli admin user` | | `woodpecker-cli log-level` | `woodpecker-cli admin log-level` | | `woodpecker-cli secret --organization` | `woodpecker-cli org secret` | | `woodpecker-cli deploy` | `woodpecker-cli pipeline deploy` | | `woodpecker-cli log` | `woodpecker-cli pipeline log` | | `woodpecker-cli cron` | `woodpecker-cli repo cron` | | `woodpecker-cli secret --repository` | `woodpecker-cli repo secret` | | `woodpecker-cli pipeline logs` | `woodpecker-cli pipeline log show` | | `woodpecker-cli (registry,secret,...) info` | `woodpecker-cli (registry,secret,...) show` | ([#4467](https://github.com/woodpecker-ci/woodpecker/pull/4467) and [#4481](https://github.com/woodpecker-ci/woodpecker/pull/4481)) #### API changes - Removed deprecated `registry/` endpoint. Use `registries`, `/authorize/token` #### Miscellaneous - For `woodpecker-cli` containers, `/woodpecker` has been set as the default `workdir` - Plugin filters for secrets (in the "secrets" repo settings) can now validate against tags. Additionally, the description has been updated to reflect that these filters only apply to plugins ([#4069](https://github.com/woodpecker-ci/woodpecker/pull/4069)). - SDK changes: - The SDK fields `start_time`, `end_time`, `created_at`, `started_at`, `finished_at` and `reviewed_at` have been renamed to `started`, `finished`, `created`, `started`, `finished`, `reviewed` ([#3968](https://github.com/woodpecker-ci/woodpecker/pull/3968)) - The `trusted` field of the repo model was changed from `boolean` to `object` ([#4025](https://github.com/woodpecker-ci/woodpecker/pull/4025)) - CRON definitions now follow standard Linux syntax without seconds. An automatic migration will attempt to update your settings - ensure the update completes successfully. Example definition for a CRON job running at 8 am daily: 2.x: ```sh 0 0 8 * * * ``` 3.x: ```sh 0 8 * * * ``` - Native Let's Encrypt certificate support has been dropped as it was almost unused and causing frequent issues. Let's Encrypt needs to be set up standalone now. The SSL key pair can still be used in `WOODPECKER_SERVER_CERT` and `WOODPECKER_SERVER_KEY` as an alternative to using a reverse proxy for TLS termination. ([#4541](https://github.com/woodpecker-ci/woodpecker/pull/4541)) - The filename of the CLI binary changed for DEB and RPM packages, it is now called `woodpecker-cli` instead of `woodpecker`. ### Admin-facing migrations #### Updated tokens The Webhook tokens have been changed for enhanced security and therefore existing repositories need to be updated using the `Repair all` button in the admin settings ([#4013](https://github.com/woodpecker-ci/woodpecker/pull/4013)). #### Image tags - The `latest` tag has been dropped to avoid accidental major version upgrades. A dedicated semver tag specification must be used, i.e., either a fixed version (like `v3.0.0`) or a rolling tag (e.g. `v3.0` or `v3`). - Previously, some (official) plugins were granted the `privileged` option by default to allow simplified usage. To streamline this process and enhance security transparency, no plugin is granted the `privileged` options by default anymore. To allow the use of these plugins in >= 3.0, they must be set explicitly through `WOODPECKER_PLUGINS_PRIVILEGED` on the admin side. This change mainly impacts the use of the `woodpeckerci/plugin-docker-buildx` plugin, which now will not work anymore unless explicitly listed through this env var ([#4053](https://github.com/woodpecker-ci/woodpecker/pull/4053)) - Environment variable deprecations: | Deprecated Variable | New Variable | | -------------------------------- | ------------------------------------ | | `WOODPECKER_LOG_XORM` | `WOODPECKER_DATABASE_LOG` | | `WOODPECKER_LOG_XORM_SQL` | `WOODPECKER_DATABASE_LOG_SQL` | | `WOODPECKER_FILTER_LABELS` | `WOODPECKER_AGENT_LABELS` | | `WOODPECKER_ESCALATE` | `WOODPECKER_PLUGINS_PRIVILEGED` | | `WOODPECKER_DEFAULT_CLONE_IMAGE` | `WOODPECKER_DEFAULT_CLONE_PLUGIN` | | `WOODPECKER_DEV_OAUTH_HOST` | `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` | | `WOODPECKER_DEV_GITEA_OAUTH_URL` | `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` | | `WOODPECKER_ROOT_PATH` | `WOODPECKER_HOST` | | `WOODPECKER_ROOT_URL` | `WOODPECKER_HOST` | - The resource limit settings for the "docker" backend were moved from the server into agent configuration. This allows setting limits on an agent-level which allows greater resource definition granularity ([#3174](https://github.com/woodpecker-ci/woodpecker/pull/3174)) - "Kubernetes" backend: previously the image pull secret name was hard-coded to `regcred`. To allow more flexibility and specifying multiple pull secrets, the default has been removed. Image pull secrets must now be set explicitly via env var `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` ([#4005](https://github.com/woodpecker-ci/woodpecker/pull/4005)) - Webhook signatures now use the `rfc9421` protocol - Git is now the only officially supported SCM. No others were supported previously, but the existence of the env var `CI_REPO_SCM` indicated that others might be. The env var has now been removed including unused code associated with it. ([#4346](https://github.com/woodpecker-ci/woodpecker/pull/4346)) #### Rootless images Woodpecker now supports running rootless images by adjusting the entrypoints and directory permissions in the containers in a way that allows non-privileged users to execute tasks. In addition, all images published by Woodpecker (Server, Agent, CLI) now use a non-privileged user (`woodpecker` with UID and GID `1000`) by default. If you have volumes attached to the containers, you may need to change the ownership of these directories from `root` to `woodpecker` by executing `chown -R 1000:1000 `. :::info The agent image must remain rootful by default to be able to mount the Docker socket when Woodpecker is used with the `docker` backend. The helm chart will start to use a non-privileged user by utilizing `securityContext`. Running a completely rootless agent with the `docker` backend may be possible by using a rootless docker daemon. However, this requires more work and is currently not supported. ::: ## 2.7.2 To secure your instance, set `WOODPECKER_PLUGINS_PRIVILEGED` to only allow specific versions of the `woodpeckerci/plugin-docker-buildx` plugin, use version 5.0.0 or above. This prevents older, potentially unstable versions from being privileged. For example, to allow only version 5.0.0, use: ```bash WOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0 ``` To allow multiple versions, you can separate them with commas: ```bash WOODPECKER_PLUGINS_PRIVILEGED=woodpeckerci/plugin-docker-buildx:5.0.0,woodpeckerci/plugin-docker-buildx:5.1.0 ``` This setup ensures only specified, stable plugin versions are given privileged access. Read more about it in [#4213](https://github.com/woodpecker-ci/woodpecker/pull/4213) ## 2.0.0 - Dropped deprecated `CI_BUILD_*`, `CI_PREV_BUILD_*`, `CI_JOB_*`, `*_LINK`, `CI_SYSTEM_ARCH`, `CI_REPO_REMOTE` built-in environment variables - Deprecated `platform:` filter in favor of `labels:`, [read more](/docs/usage/workflow-syntax#filter-by-platform) - Secrets `event` property was renamed to `events` and `image` to `images` as both are lists. The new property `events` / `images` has to be used in the api. The old properties `event` and `image` were removed. - The secrets `plugin_only` option was removed. Secrets with images are now always only available for plugins using listed by the `images` property. Existing secrets with a list of `images` will now only be available to the listed images if they are used as a plugin. - Removed `build` alias for `pipeline` command in CLI - Removed `ssh` backend. Use an agent directly on the SSH machine using the `local` backend. - Removed `/hook` and `/stream` API paths in favor of `/api/(hook|stream)`. You may need to use the "Repair repository" button in the repo settings or "Repair all" in the admin settings to recreate the forge hook. - Removed `WOODPECKER_DOCS` config variable - Renamed `link` to `url` (including all API fields) - Deprecated `CI_COMMIT_URL` env var, use `CI_PIPELINE_FORGE_URL` ## 1.0.0 - The signature used to verify extension calls (like those used for the [config-extension](/docs/next/usage/extensions/configuration-extension)) done by the Woodpecker server switched from using a shared-secret HMac to an ed25519 key-pair. Read more about it at the [config-extensions](/docs/next/usage/extensions/configuration-extension) documentation. - Refactored support for old agent filter labels and expressions. Learn how to use the new [filter](/docs/usage/workflow-syntax#labels) - Renamed step environment variable `CI_SYSTEM_ARCH` to `CI_SYSTEM_PLATFORM`. Same applies for the cli exec variable. - Renamed environment variables `CI_BUILD_*` and `CI_PREV_BUILD_*` to `CI_PIPELINE_*` and `CI_PREV_PIPELINE_*`, old ones are still available but deprecated - Renamed environment variables `CI_JOB_*` to `CI_STEP_*`, old ones are still available but deprecated - Renamed environment variable `CI_REPO_REMOTE` to `CI_REPO_CLONE_URL`, old is still available but deprecated - Renamed environment variable `*_LINK` to `*_URL`, old ones are still available but deprecated - Renamed API endpoints for pipelines (`//builds/` -> `//pipelines/`), old ones are still available but deprecated - Updated Prometheus gauge `build_*` to `pipeline_*` - Updated Prometheus gauge `*_job_*` to `*_step_*` - Renamed config env `WOODPECKER_MAX_PROCS` to `WOODPECKER_MAX_WORKFLOWS` (still available as fallback) - The pipelines are now also read from `.yaml` files, the new default order is `.woodpecker/*.yml` and `.woodpecker/*.yaml` (without any prioritization) -> `.woodpecker.yml` -> `.woodpecker.yaml` - Dropped support for [Coding](https://coding.net/), [Gogs](https://gogs.io) and Bitbucket Server (Stash). - `/api/queue/resume` & `/api/queue/pause` endpoint methods were changed from `GET` to `POST` - rename `pipeline:` key in your workflow config to `steps:` - If you want to migrate old logs to the new format, watch the error messages on start. If there are none we are good to go, else you have to plan a migration that can take hours. Set `WOODPECKER_MIGRATIONS_ALLOW_LONG` to true and let it run. - Using `repo-id` in favor of `owner/repo` combination - :warning: The api endpoints `/api/repos/{owner}/{repo}/...` were replaced by new endpoints using the repos id `/api/repos/{repo-id}` - To find the id of a repo use the `/api/repos/lookup/{repo-full-name-with-slashes}` endpoint. - The existing badge endpoint `/api/badges/{owner}/{repo}` will still work, but whenever possible try to use the new endpoint using the `repo-id`: `/api/badges/{repo-id}`. - The UI urls for a repository changed from `/repos/{owner}/{repo}/...` to `/repos/{repo-id}/...`. You will be redirected automatically when using the old url. - The woodpecker-go api-client is now using the `repo-id` instead of `owner/repo` for all functions - Using `org-id` in favour of `owner` name - :warning: The api endpoints `/api/orgs/{owner}/...` were replaced by new endpoints using the orgs id `/api/repos/{org-id}` - To find the id of orgs use the `/api/orgs/lookup/{org_full_name}` endpoint. - The UI urls for a organization changed from `/org/{owner}/...` to `/orgs/{org-id}/...`. You will be redirected automatically when using the old url. - The woodpecker-go api-client is now using the `org-id` instead of `org name` for all functions - The `command:` field has been removed from steps. If you were using it, please check if the entrypoint of the image you used is a shell. - If it is a shell, simply rename `command:` to `commands:`. - If it's not, you need to prepend the entrypoint before and also rename it (e.g., `commands: `). ## 0.15.0 - Default value for custom pipeline path is now empty / un-set which results in following resolution: `.woodpecker/*.yml` -> `.woodpecker.yml` -> `.drone.yml` Only projects created after updating will have an empty value by default. Existing projects will stick to the current pipeline path which is `.drone.yml` in most cases. Read more about it at the [Project Settings](/docs/usage/project-settings#pipeline-path) - From version `0.15.0` ongoing there will be three types of docker images: `latest`, `next` and `x.x.x` with an alpine variant for each type like `latest-alpine`. If you used `latest` before to try pre-release features you should switch to `next` after this release. - Dropped support for `DRONE_*` environment variables. The according `WOODPECKER_*` variables must be used instead. Additionally some alternative namings have been removed to simplify maintenance: - `WOODPECKER_AGENT_SECRET` replaces `WOODPECKER_SECRET`, `DRONE_SECRET`, `WOODPECKER_PASSWORD`, `DRONE_PASSWORD` and `DRONE_AGENT_SECRET`. - `WOODPECKER_HOST` replaces `DRONE_HOST` and `DRONE_SERVER_HOST`. - `WOODPECKER_DATABASE_DRIVER` replaces `DRONE_DATABASE_DRIVER` and `DATABASE_DRIVER`. - `WOODPECKER_DATABASE_DATASOURCE` replaces `DRONE_DATABASE_DATASOURCE` and `DATABASE_CONFIG`. - Dropped support for `DRONE_*` environment variables in pipeline steps. Pipeline meta-data can be accessed with `CI_*` variables. - `CI_*` prefix replaces `DRONE_*` - `CI` value is now `woodpecker` - `DRONE=true` has been removed - Some variables got deprecated and will be removed in future versions. Please migrate to the new names. Same applies for `DRONE_` of them. - CI_ARCH => use CI_SYSTEM_ARCH - CI_COMMIT => CI_COMMIT_SHA - CI_TAG => CI_COMMIT_TAG - CI_PULL_REQUEST => CI_COMMIT_PULL_REQUEST - CI_REMOTE_URL => use CI_REPO_REMOTE - CI_REPO_BRANCH => use CI_REPO_DEFAULT_BRANCH - CI_PARENT_BUILD_NUMBER => use CI_BUILD_PARENT - CI_BUILD_TARGET => use CI_BUILD_DEPLOY_TARGET - CI_DEPLOY_TO => use CI_BUILD_DEPLOY_TARGET - CI_COMMIT_AUTHOR_NAME => use CI_COMMIT_AUTHOR - CI_PREV_COMMIT_AUTHOR_NAME => use CI_PREV_COMMIT_AUTHOR - CI_SYSTEM => use CI_SYSTEM_NAME - CI_BRANCH => use CI_COMMIT_BRANCH - CI_SOURCE_BRANCH => use CI_COMMIT_SOURCE_BRANCH - CI_TARGET_BRANCH => use CI_COMMIT_TARGET_BRANCH For all available variables and their descriptions have a look at [built-in-environment-variables](/docs/usage/environment#built-in-environment-variables). - Prometheus metrics have been changed from `drone_*` to `woodpecker_*` - Base path has moved from `/var/lib/drone` to `/var/lib/woodpecker` - Default workspace base path has moved from `/drone` to `/woodpecker` - Default SQLite database location has changed: - `/var/lib/drone/drone.sqlite` -> `/var/lib/woodpecker/woodpecker.sqlite` - `drone.sqlite` -> `woodpecker.sqlite` - Plugin Settings moved into `settings` section: ```diff steps: something: image: my/plugin - setting1: foo - setting2: bar + settings: + setting1: foo + setting2: bar ``` - `WOODPECKER_DEBUG` option for server and agent got removed in favor of `WOODPECKER_LOG_LEVEL=debug` - Remove unused server flags which can safely be removed from your server config: `WOODPECKER_QUIC`, `WOODPECKER_GITHUB_SCOPE`, `WOODPECKER_GITHUB_GIT_USERNAME`, `WOODPECKER_GITHUB_GIT_PASSWORD`, `WOODPECKER_GITHUB_PRIVATE_MODE`, `WOODPECKER_GITEA_GIT_USERNAME`, `WOODPECKER_GITEA_GIT_PASSWORD`, `WOODPECKER_GITEA_PRIVATE_MODE`, `WOODPECKER_GITLAB_GIT_USERNAME`, `WOODPECKER_GITLAB_GIT_PASSWORD`, `WOODPECKER_GITLAB_PRIVATE_MODE` - Dropped support for manually setting the agents platform with `WOODPECKER_PLATFORM`. The platform is now automatically detected. - Use `WOODPECKER_STATUS_CONTEXT` instead of the deprecated options `WOODPECKER_GITHUB_CONTEXT` and `WOODPECKER_GITEA_CONTEXT`. ## 0.14.0 No breaking changes ## From Drone :::warning Migration from Drone is only possible if you were running Drone <= v0.8. ::: 1. Make sure you are already running Drone v0.8 2. Upgrade to Woodpecker v0.14.4, migration will be done during startup 3. Upgrade to the latest Woodpecker version. Pay attention to the breaking changes listed above. ================================================ FILE: docs/src/pages/versions.md ================================================ # Versions Woodpecker is having two different kinds of releases: **stable** and **next**. If you want all (new) features of Woodpecker while supporting us with feedback and are willing to accept some possible bugs from time to time, you should use the next release, otherwise use the stable release. We plan to release a new version every four weeks and will release the next version as a stable version. ## Stable version The **stable** releases are official versions following [semver](https://semver.org/). By default, only the latest stable release will receive bug fixes. Once a new major or minor release is available, previous minor versions might receive security patches, but won't be updated with bug fixes anymore (so called backporting) by default. ### Breaking changes As of semver guidelines, breaking changes will be released as a major version. We will hold back breaking changes to not release many majors each containing just a few breaking changes. Prior to the release of a major version, a release candidate (RC) will be published to allow easy testing, the actual release will be about a week later. ### Deprecations & migrations All deprecations and migrations for Woodpecker users and instance admins are documented in the [migration guide](/migrations). ## Next version (current state of the `main` branch) The **next** version contains all bugfixes and features from `main` branch. Normally it should be pretty stable, but as its frequently updated, it might contain some bugs from time to time. There are no binaries for this version. ## Past versions (Not maintained anymore) Here you can find documentation for previous versions of Woodpecker. [Changelog](https://github.com/woodpecker-ci/woodpecker/blob/main/CHANGELOG.md) | | | | | ------- | ---------- | ------------------------------------------------------------------------------------- | | 3.14.0 | 2026-05-01 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.14.0/docs/docs/) | | 3.13.0 | 2026-01-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.13.0/docs/docs/) | | 3.12.0 | 2025-11-18 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.12.0/docs/docs/) | | 3.11.0 | 2025-10-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.11.0/docs/docs/) | | 3.10.0 | 2025-09-28 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.10.0/docs/docs/) | | 3.9.0 | 2025-08-20 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.9.0/docs/docs/) | | 3.8.0 | 2025-07-05 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.8.0/docs/docs/) | | 3.7.0 | 2025-06-06 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.7.0/docs/docs/) | | 3.6.0 | 2025-05-06 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.6.0/docs/docs/) | | 3.5.0 | 2025-04-02 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.5.0/docs/docs/) | | 3.4.0 | 2025-03-17 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.4.0/docs/docs/) | | 3.3.0 | 2025-03-04 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.3.0/docs/docs/) | | 3.2.0 | 2025-02-26 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.2.0/docs/docs/) | | 3.1.0 | 2025-02-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.1.0/docs/docs/) | | 3.0.0 | 2025-01-11 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v3.0.0/docs/docs/) | | 2.8.3 | 2025-01-11 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.3/docs/docs/) | | 2.8.2 | 2024-12-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.2/docs/docs/) | | 2.8.1 | 2024-12-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.1/docs/docs/) | | 2.8.0 | 2024-11-28 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.8.0/docs/docs/) | | 2.7.3 | 2024-11-05 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.3/docs/docs/) | | 2.7.2 | 2024-11-03 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.2/docs/docs/) | | 2.7.1 | 2024-09-07 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.1/docs/docs/) | | 2.7.0 | 2024-07-18 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.7.0/docs/docs/) | | 2.6.1 | 2024-07-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.6.1/docs/docs/) | | 2.6.0 | 2024-06-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.6.0/docs/docs/) | | 2.5.0 | 2024-06-01 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.5.0/docs/docs/) | | 2.4.1 | 2024-03-20 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.4.1/docs/docs/) | | 2.4.0 | 2024-03-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.4.0/docs/docs/) | | 2.3.0 | 2024-01-31 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.3.0/docs/docs/) | | 2.2.2 | 2024-01-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.2.2/docs/docs/) | | 2.2.1 | 2024-01-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.2.1/docs/docs/) | | 2.2.0 | 2024-01-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.2.0/docs/docs/) | | 2.1.1 | 2023-12-27 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.1.1/docs/docs/) | | 2.1.0 | 2023-12-26 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.1.0/docs/docs/) | | 2.0.0 | 2023-12-23 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v2.0.0/docs/docs/) | | 1.0.5 | 2023-11-09 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.5/docs/docs/) | | 1.0.4 | 2023-11-05 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.4/docs/docs/) | | 1.0.3 | 2023-10-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.3/docs/docs/) | | 1.0.2 | 2023-08-16 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.2/docs/docs/) | | 1.0.1 | 2023-08-08 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.1/docs/docs/) | | 1.0.0 | 2023-07-29 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v1.0.0/docs/docs/) | | 0.15.11 | 2023-07-12 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.11/docs/docs/) | | 0.15.10 | 2023-07-09 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.10/docs/docs/) | | 0.15.9 | 2023-05-11 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.9/docs/docs/) | | 0.15.8 | 2023-04-29 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.8/docs/docs/) | | 0.15.7 | 2023-03-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.7/docs/docs/) | | 0.15.6 | 2022-12-23 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.6/docs/docs/) | | 0.15.5 | 2022-10-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.5/docs/docs/) | | 0.15.4 | 2022-09-06 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.4/docs/docs/) | | 0.15.3 | 2022-06-16 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.3/docs/docs/) | | 0.15.2 | 2022-06-14 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.2/docs/docs/) | | 0.15.1 | 2022-04-13 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.1/docs/docs/) | | 0.15.0 | 2022-02-24 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.15.0/docs/docs/) | | 0.14.4 | 2022-01-31 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.4/docs/docs/) | | 0.14.3 | 2021-10-30 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.3/docs/docs/) | | 0.14.2 | 2021-10-19 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.2/docs/docs/) | | 0.14.1 | 2021-09-21 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.1/docs/docs/) | | 0.14.0 | 2021-08-01 | [Documentation](https://github.com/woodpecker-ci/woodpecker/tree/v0.14.0/docs/docs/) | If you are using an older version of Woodpecker and would like to view docs for this version, please use GitHub to browse the repository at your tag. ================================================ FILE: docs/tsconfig.json ================================================ { "extends": "@docusaurus/tsconfig", "include": ["src/"] } ================================================ FILE: docs/versioned_docs/version-2.8/10-intro/index.md ================================================ # Welcome to Woodpecker Woodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics. ## Have you ever heard of CI/CD or pipelines? Don't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of checks, tests and routines along the way. A typical pipeline might include the following steps: 1. Running tests 2. Building your application 3. Deploying your application [Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd) ## Do you know containers? If you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/). ## Already have access to a Woodpecker instance? Then you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md). ## Want to start from scratch and deploy your own Woodpecker instance? Woodpecker is [pretty lightweight](../30-administration/00-getting-started.md#hardware-requirements) and will even run on your Raspberry Pi. You can follow the [deployment guide](../30-administration/00-getting-started.md) to set up your own Woodpecker instance. ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/10-intro.md ================================================ # Your first pipeline Let's get started and create your first pipeline. ## 1. Repository Activation To activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click. ![new repository list](repo-new.png) To enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something that is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.). ## 2. Define first workflow After enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository: ```yaml title=".woodpecker/my-first-workflow.yaml" when: - event: push branch: main steps: - name: build image: debian commands: - echo "This is the build step" - echo "binary-data-123" > executable - name: a-test-step image: golang:1.16 commands: - echo "Testing ..." - ./executable ``` **So what did we do here?** 1. We defined your first workflow file `my-first-workflow.yaml`. 2. This workflow will be executed when a push event happens on the `main` branch, because we added a filter using the `when` section: ```diff + when: + - event: push + branch: main ... ``` 3. We defined two steps: `build` and `a-test-step` The steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`. In the `build` step we use the `debian` image and build a "binary file" called `executable`. In the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it. You can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to: ```diff steps: - name: build - image: debian + image: mycompany/image-with-awscli commands: - aws help ``` ## 3. Push the file and trigger first pipeline If you push this file to your repository now, Woodpecker will already execute your first pipeline. You can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository. ![pipeline view](./pipeline.png) As you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps. This for example allows the first step to build your application using your source code and as the second step will receive the same workspace it can use the previously built binary and test it. ## 4. Use a plugin for reusable tasks Sometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md). If you want to get a Slack notification after your pipeline has finished, you can add a Slack plugin to your pipeline: ```yaml --- - name: notify me on Slack image: plugins/slack settings: channel: developers username: woodpecker password: from_secret: slack_token when: status: [success, failure] # This will execute the step on success and failure ``` To configure a plugin you can use the `settings` section. Sometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md). Similar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed. Learn more about [plugins](./51-plugins/51-overview.md). As you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md). ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/100-troubleshooting.md ================================================ # Troubleshooting ## How to debug clone issues (And what to do with an error message like `fatal: could not read Username for 'https://': No such device or address`) This error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`: ```ini WOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true ``` If that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container "hang": ```yaml skip_clone: true steps: build: image: debian:stable-backports commands: - apt update - apt install -y inetutils-ping wget - ping -c 4 git.example.com - wget git.example.com - sleep 9999999 ``` Get the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline: ```bash git init git remote add origin https://git.example.com/username/repo.git git fetch --no-tags origin +refs/heads/branch: ``` (replace the url AND the branch with the correct values, use your username and password as log in values) ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/15-terminology/architecture.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 226, "versionNonce": 1002880859, "isDeleted": false, "id": "UczUX5VuNnCB1rVvUJVfm", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.098092529257, "y": 320.8758615860986, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 472.8823858375721, "height": 183.19688715994928, "seed": 917720693, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 161, "versionNonce": 286006267, "isDeleted": false, "id": "sKPZmBSWUdAYfBs4ByItH", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 539.5451038202509, "y": 345.2419383247636, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 82.46875, "height": 32.199999999999996, "seed": 1485551573, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Server", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Server", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 333, "versionNonce": 448586907, "isDeleted": false, "id": "_A8uznhnpXuQBYzjP-iVx", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 649.8080506852966, "y": 427.60908869342575, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 136, "height": 60, "seed": 1783625013, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "r90dckf8trHemYzEwCgCW" }, { "id": "XxfJWnHonmvNOJzMFSlie", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 298, "versionNonce": 1244067771, "isDeleted": false, "id": "r90dckf8trHemYzEwCgCW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 703.8080506852966, "y": 441.5090886934257, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 28, "height": 32.199999999999996, "seed": 660965013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113383, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "UI", "textAlign": "center", "verticalAlign": "middle", "containerId": "_A8uznhnpXuQBYzjP-iVx", "originalText": "UI", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 105, "versionNonce": 265992667, "isDeleted": false, "id": "v2eEwSOSRQBZ79O6wyzGf", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 800.9240766836483, "y": 421.4987043996123, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 135.3671503686619, "height": 62.2689029398432, "seed": 1115810805, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "svsVhxCbatcLj7lQLch0P" }, { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 83, "versionNonce": 1706870395, "isDeleted": false, "id": "svsVhxCbatcLj7lQLch0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 828.1594096804793, "y": 436.53315586953386, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 80.896484375, "height": 32.199999999999996, "seed": 2074781013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "GRPC", "textAlign": "center", "verticalAlign": "middle", "containerId": "v2eEwSOSRQBZ79O6wyzGf", "originalText": "GRPC", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 270, "versionNonce": 418660123, "isDeleted": false, "id": "hSrrwwnm9y7R-_CnJtaK1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.567103519039, "y": 556.4146894573112, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 1983197877, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 154, "versionNonce": 871605179, "isDeleted": false, "id": "8tsYgVssKnBd_Zw1QuqNz", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1298.4367898442752, "y": 566.567242947784, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 96.5234375, "height": 32.199999999999996, "seed": 1321669653, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent 1", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent 1", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 182, "versionNonce": 1323136091, "isDeleted": false, "id": "eeugZg73_yD_6uLBBgmcX", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 404.5001910129067, "y": 707.1233710221009, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 210.068359375, "height": 32.199999999999996, "seed": 1901447541, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "User => Browser", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "User => Browser", "lineHeight": 1.15, "baseline": 25 }, { "type": "ellipse", "version": 106, "versionNonce": 1501835515, "isDeleted": false, "id": "mlDhl4OOc-H1tNgh77AAW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 482.5857164810477, "y": 602.4394551739279, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 46.024748503793035, "height": 44.21988070606176, "seed": 791073493, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "line", "version": 166, "versionNonce": 627726747, "isDeleted": false, "id": "ADEXzdYAhvj-_wVRftTIg", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 459.12202200277807, "y": 697.1964604319912, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 80.31792517362464, "height": 31.585599568061298, "seed": 349155381, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": null, "points": [ [ 0, 0 ], [ 42.415150610916044, -28.87829787146393 ], [ 80.31792517362464, 2.7073016965973693 ] ] }, { "type": "rectangle", "version": 231, "versionNonce": 801271355, "isDeleted": false, "id": "xmz4J-rxLIjfUQ4q19PjD", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 516.8788931508789, "y": 870.4664542146543, "strokeColor": "#f08c00", "backgroundColor": "#fff4e6", "width": 385.34512717560705, "height": 60.464035142111264, "seed": 3531157, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 93, "versionNonce": 728690395, "isDeleted": false, "id": "gSbpry_947XArfI7b6AAL", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 636.1468430141358, "y": 878.5884970070326, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 132.2890625, "height": 32.199999999999996, "seed": 1989076725, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Autoscaler", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Autoscaler", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 118, "versionNonce": 1258445691, "isDeleted": false, "id": "WVy0mdTGbUx08RuxdQUH8", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 523.3741602213286, "y": 907.372811672524, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 369.1484375, "height": 18.4, "seed": 979386453, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Starts agents based on amount of pending pipelines", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Starts agents based on amount of pending pipelines", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 373, "versionNonce": 1254044699, "isDeleted": false, "id": "0Y1RcqzVFBFqh-wy-APMI", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1232.1955835481922, "y": 605.8737363119278, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 292.6171875, "height": 18.4, "seed": 561999285, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Executes pending workflows of a pipeline", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Executes pending workflows of a pipeline", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 630, "versionNonce": 983038139, "isDeleted": false, "id": "lGumbhMs3xx1vU2632hli", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 505.62283787078286, "y": 383.42044095379515, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 158.015625, "height": 36.8, "seed": 722595605, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Central unit of a \nWoodpecker instance ", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Central unit of a \nWoodpecker instance ", "lineHeight": 1.15, "baseline": 32 }, { "type": "rectangle", "version": 131, "versionNonce": 137308507, "isDeleted": false, "id": "PbSQXehWVLYcQGXYFpd-B", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 971.7123256059622, "y": 171.06951064323448, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 274.3443117379593, "height": 74.90311522655017, "seed": 1435321461, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 96, "versionNonce": 1222067707, "isDeleted": false, "id": "2P2tz29C_2sUzVNSpaG17", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.5206131439782, "y": 183.12082907329545, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 73.14453125, "height": 32.199999999999996, "seed": 884403669, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Forge", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Forge", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 141, "versionNonce": 1133694619, "isDeleted": false, "id": "0eYhFYPuRanZ7wkR2OlHO", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 986.864582863368, "y": 225.1223531590797, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 247.234375, "height": 18.4, "seed": 1201957685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "HK1jmIcPmM6Us6Jrynobb", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Github, Gitea, Github, Bitbucket, ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Github, Gitea, Github, Bitbucket, ...", "lineHeight": 1.15, "baseline": 14 }, { "type": "rectangle", "version": 55, "versionNonce": 991183675, "isDeleted": false, "id": "dihpRzuIc-UoRSsOI33SZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 820.419424341303, "y": 340.29123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 247151765, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "bcUL-u4zkLA9CLG2YdaeN" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 38, "versionNonce": 2008949723, "isDeleted": false, "id": "bcUL-u4zkLA9CLG2YdaeN", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 831.853994653803, "y": 358.79123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 94.130859375, "height": 23, "seed": 1638982133, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Webhooks", "textAlign": "center", "verticalAlign": "middle", "containerId": "dihpRzuIc-UoRSsOI33SZ", "originalText": "Webhooks", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 93, "versionNonce": 295891067, "isDeleted": false, "id": "Bphhue86mMXHN4klGamM3", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 697.3018309300141, "y": 339.607928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 92986197, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "0YxY2hEPyDWFqR8_-f6bn" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 87, "versionNonce": 2055547163, "isDeleted": false, "id": "0YxY2hEPyDWFqR8_-f6bn", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 727.4522215550141, "y": 358.107928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 56.69921875, "height": 23, "seed": 43952309, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "OAuth", "textAlign": "center", "verticalAlign": "middle", "containerId": "Bphhue86mMXHN4klGamM3", "originalText": "OAuth", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 284, "versionNonce": 1205292475, "isDeleted": false, "id": "HK1jmIcPmM6Us6Jrynobb", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1205.6453201409104, "y": 250.4849674923464, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 272.1094712799886, "height": 94.31865813977868, "seed": 982632981, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "uDIWJ5K5mEBL9QaiNk3cS" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "0eYhFYPuRanZ7wkR2OlHO", "focus": -0.8418551162334328, "gap": 6.962614333266799 }, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ -69.68740859223726, 65.87860410965993 ], [ -272.1094712799886, 94.31865813977868 ] ] }, { "type": "text", "version": 53, "versionNonce": 1803962459, "isDeleted": false, "id": "uDIWJ5K5mEBL9QaiNk3cS", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1050.575099048673, "y": 297.96357160200637, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 170.765625, "height": 36.8, "seed": 1046069109, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "sends events like push, \ntag, ...", "textAlign": "center", "verticalAlign": "middle", "containerId": "HK1jmIcPmM6Us6Jrynobb", "originalText": "sends events like push, tag, ...", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 487, "versionNonce": 335895291, "isDeleted": false, "id": "Kqbwk_qfkALJfhtCIr2eS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 792.0835609101814, "y": 316.38601649373913, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 176.92139414789008, "height": 122.73778943055902, "seed": 1681656021, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yvJTQ64RU50N6-hxEQlkl" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "UczUX5VuNnCB1rVvUJVfm", "focus": -0.03867359238356983, "gap": 4.489845092359474 }, "endBinding": { "elementId": "PbSQXehWVLYcQGXYFpd-B", "focus": 0.7798878042817562, "gap": 2.707370547890605 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 60.422360349016344, -71.97786730696657 ], [ 176.92139414789008, -122.73778943055902 ] ] }, { "type": "text", "version": 62, "versionNonce": 301106427, "isDeleted": false, "id": "yvJTQ64RU50N6-hxEQlkl", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 773.7910775091977, "y": 226.00814918677256, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 157.4296875, "height": 36.8, "seed": 500049461, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "allows users to login \nusing existing account", "textAlign": "center", "verticalAlign": "middle", "containerId": "Kqbwk_qfkALJfhtCIr2eS", "originalText": "allows users to login using existing account", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 393, "versionNonce": 598459861, "isDeleted": false, "id": "TvtonmlV0W8__pnTG-wVZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 936.9267543177084, "y": 458.95033086418084, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 215.17788326846676, "height": 93.99151368376693, "seed": 234198933, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "rFf6NIofw6UBOyAFwg0Kn" } ], "updated": 1697530127259, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.30339107267010673, "gap": 1 }, "endBinding": { "elementId": "hSrrwwnm9y7R-_CnJtaK1", "focus": -0.14057158065513534, "gap": 3.4728449093634026 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 130.0760301643047, 42.90930518030268 ], [ 215.17788326846676, 93.99151368376693 ] ] }, { "type": "text", "version": 8, "versionNonce": 1693330843, "isDeleted": false, "id": "rFf6NIofw6UBOyAFwg0Kn", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 997.4942845557462, "y": 473.9409015069133, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 1592253685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "TvtonmlV0W8__pnTG-wVZ", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 270, "versionNonce": 1855882619, "isDeleted": false, "id": "5tl702dfcvJDLz9aIFU0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 886.0581619083632, "y": 485.67004123832135, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 174.09447592006472, "height": 326.4905563076211, "seed": 1479177813, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "apyMCAv2GIN_yzHXwX4tY" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.1341191028023529, "gap": 1.9024338988657519 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "focus": -0.7088661407505865, "gap": 4.060573862784622 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 44.14165353942735, 196.18483635907205 ], [ 174.09447592006472, 326.4905563076211 ] ] }, { "type": "text", "version": 66, "versionNonce": 2007745083, "isDeleted": false, "id": "apyMCAv2GIN_yzHXwX4tY", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 849.4927841977906, "y": 663.4548775973934, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 882041781, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "5tl702dfcvJDLz9aIFU0P", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 347, "versionNonce": 1353818811, "isDeleted": false, "id": "XxfJWnHonmvNOJzMFSlie", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 534.9278465333664, "y": 595.2199151317081, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 113.88020415193023, "height": 119.81968366814112, "seed": 944153877, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "_A8uznhnpXuQBYzjP-iVx", "focus": 0.5397285671082249, "gap": 1 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 113.88020415193023, -119.81968366814112 ] ] }, { "type": "rectangle", "version": 61, "versionNonce": 1099141979, "isDeleted": false, "id": "j56ZKRwmXk72nHrZzLz_1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1081.8110514012087, "y": 652.5253283508498, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 566.7373014532342, "height": 68.58600908319681, "seed": 112933493, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 82, "versionNonce": 1879994363, "isDeleted": false, "id": "cAVYXfBRnfuGAv7QTQVow", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1300.6584159706863, "y": 658.8425033454967, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 77.83203125, "height": 23, "seed": 951460821, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Backend", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Backend", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 376,- add some images explaining the architecture & terminology with pipeline -> workflow -> step - combine advanced config usage - rename pipeline syntax to workflow syntax (and most references to pipeline steps etc as well) - update agent registration part - add bug note to secrets encryption setting - remove usage from readme to point to up-to-date docs page - typos - closes #1408 --------- "angle": 0, "x": 1094.1972977313717, "y": 681.8988272758752, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 530.9453125, "height": 55.199999999999996, "seed": 843899189, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "lineHeight": 1.15, "baseline": 50 }, { "type": "rectangle", "version": 384, "versionNonce": 1778969915, "isDeleted": false, "id": "pxF49EKDNO6IZq_34i7bY", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1064.2132116912126, "y": 754.5018564383092, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 954528405, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "arrow", "version": 154, "versionNonce": 1988988379, "isDeleted": false, "id": "05EJzh4NLXxemaKAmdi5n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 904.0288881242177, "y": 882.4966027880746, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 158.83070714434325, "height": 32.735025983189644, "seed": 1228134389, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yNxAOEPZu_Jl7mnI01OXs" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "xmz4J-rxLIjfUQ4q19PjD", "gap": 1.8048677977312764, "focus": 0.31250963573550006 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "gap": 1.353616422651612, "focus": 0.36496042109885213 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 158.83070714434325, -32.735025983189644 ] ] }, { "type": "text", "version": 25, "versionNonce": 1393410779, "isDeleted": false, "id": "yNxAOEPZu_Jl7mnI01OXs", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 963.8856479463893, "y": 856.9290897964797, "strokeColor": "#f08c00", "backgroundColor": "#b2f2bb", "width": 39.1171875, "height": 18.4, "seed": 759107925, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113387, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "starts", "textAlign": "center", "verticalAlign": "middle", "containerId": "05EJzh4NLXxemaKAmdi5n", "originalText": "starts", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 187, "versionNonce": 671224603, "isDeleted": false, "id": "sSj4Pda-fo-BBYM_dzml6", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1296.0854928322988, "y": 776.6118140041631, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 104.2890625, "height": 32.199999999999996, "seed": 1381768885, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent ...", "lineHeight": 1.15, "baseline": 25 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/15-terminology/index.md ================================================ # Terminology ## Glossary - **Woodpecker CI**: The project name around Woodpecker. - **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code. - **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration. - **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC. - **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines. - **Pipeline**: A sequence of [workflows][Workflow] that are executed on the code. [Pipelines][Pipeline] are triggered by events. - **Workflow**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each [workflow][Workflow] has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker). - **Steps**: Individual commands, actions or tasks within a [workflow][Workflow]. - **Code**: Refers to the files tracked by the version control system used by the [forge][Forge]. - **Repos**: Short for repositories, these are storage locations where code is stored. - **Forge**: The hosting platform or service where the repositories are hosted. - **Workspace**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps. - **Event**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI. - **Commit**: A defined state of the code, usually associated with a version control system like Git. - **Matrix**: A configuration option that allows the execution of [workflows][Workflow] for each value in the [matrix][Matrix]. - **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow]. - **Plugins**: [Plugins][Plugin] are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings. - **Container**: A lightweight and isolated environment where commands are executed. - **YAML File**: A file format used to define and configure [workflows][Workflow]. - **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel. - **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge]. - **Service extension**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through service extensions. ## Woodpecker architecture ![Woodpecker architecture](architecture.svg) ## Pipeline, workflow & step ![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg) ## Pipeline events - `push`: A push event is triggered when a commit is pushed to a branch. - `pull_request`: A pull request event is triggered when a pull request is opened or a new commit is pushed to it. - `pull_request_closed`: A pull request closed event is triggered when a pull request is closed or merged. - `tag`: A tag event is triggered when a tag is pushed. - `release`: A release event is triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](../20-workflow-syntax.md#evaluate) with [environment variables](../50-environment.md#built-in-environment-variables).) - `manual`: A manual event is triggered when a user manually triggers a pipeline. - `cron`: A cron event is triggered when a cron job is executed. ## Conventions Sometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker: - Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()` - Use the term **pipelines** instead of the previous **builds** - Use the term **steps** instead of the previous **jobs** - Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users [Pipeline]: ../20-workflow-syntax.md [Workflow]: ../25-workflows.md [Forge]: ../../30-administration/11-forges/11-overview.md [Plugin]: ../51-plugins/51-overview.md [Workspace]: ../20-workflow-syntax.md#workspace [Matrix]: ../30-matrix-workflows.md [Docker]: ../../30-administration/22-backends/10-docker.md [Local]: ../../30-administration/22-backends/20-local.md ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/15-terminology/pipeline-workflow-step.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 97, "versionNonce": 257762037, "isDeleted": false, "id": "Y3hYdpX9r1qWfyHWs7AXT", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 393.622323134362, "y": 336.02197155458475, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 366.3936710429598, "height": 499.95605689083004, "seed": 875444373, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 67, "versionNonce": 369556565, "isDeleted": false, "id": "g1Eb010Kx_KFryVqNYWBQ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 520.0116988873679, "y": 363.32095846456355, "strokeColor": "#1971c2", "backgroundColor": "#b2f2bb", "width": 99.626953125, "height": 32.199999999999996, "seed": 1466195445, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Pipeline", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Pipeline", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 314, "versionNonce": 1983028731, "isDeleted": false, "id": "9o-DNP0YdlIGVz1kEm_hW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 407.1590381712276, "y": 410.9252244837219, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1869535061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" }, { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083624, "link": null, "locked": false }, { "type": "rectangle", "version": 156, "versionNonce": 1495247317, "isDeleted": false, "id": "q4TKpiq2KAwPaz19GdhtK", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 490.3194993196821, "y": 473.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 111355061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "ya0JzDo-4oscHIq87TZ_D" }, { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" }, { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 156, "versionNonce": 1469425461, "isDeleted": false, "id": "ya0JzDo-4oscHIq87TZ_D", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 566.0118821321821, "y": 478.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1084671509, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "q4TKpiq2KAwPaz19GdhtK", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 236, "versionNonce": 1535319541, "isDeleted": false, "id": "AOJLQFldoHd2vxVtB2jrS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 491.2218643672577, "y": 519.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 812596085, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "FRby8A9aUiKvHpM5mCdDN" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 231, "versionNonce": 28677973, "isDeleted": false, "id": "FRby8A9aUiKvHpM5mCdDN", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 583.0324112422577, "y": 524.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1849820373, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "AOJLQFldoHd2vxVtB2jrS", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 291, "versionNonce": 571598005, "isDeleted": false, "id": "2WwuMWX7YawqK0i1rDPJo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 489.6426911083554, "y": 567.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1840554549, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "UOwxmKIS0W62CFt_ffEy4" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 289, "versionNonce": 4032021, "isDeleted": false, "id": "UOwxmKIS0W62CFt_ffEy4", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 581.4532379833554, "y": 572.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 330077077, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "2WwuMWX7YawqK0i1rDPJo", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 296, "versionNonce": 1539516059, "isDeleted": false, "id": "9laL3864YWOna6NQlVDqq", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 630.0635849044402, "y": 383.14314287821776, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 294.3024370154917, "height": 36.656016722015465, "seed": 207575285, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -1.000156025347643, "gap": 27.782081605504118 }, "endBinding": { "elementId": "vS2PNUbmeBe3EPxl-dID8", "focus": 0.7761987167055517, "gap": 8.978940924346716 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 294.3024370154917, -36.656016722015465 ] ] }, { "type": "text", "version": 249, "versionNonce": 2076402229, "isDeleted": false, "id": "vS2PNUbmeBe3EPxl-dID8", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 933.3449628442786, "y": 336.02200598023114, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 301.298828125, "height": 46, "seed": 1632793173, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "A pipeline is triggered by an event\nlike a push, tag, manual", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "A pipeline is triggered by an event\nlike a push, tag, manual", "lineHeight": 1.15, "baseline": 41 }, { "type": "arrow", "version": 751, "versionNonce": 1371044827, "isDeleted": false, "id": "FU4jk6Tz6duLaaZE0Z55A", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 751.1619011845514, "y": 440.8355079324799, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 160.46519124360202, "height": 2.2452348338335923, "seed": 1331388341, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -0.6591700594229558, "gap": 3.8807513696519322 }, "endBinding": { "elementId": "wfFvnFZuh0npL9hh0ez7o", "focus": 0.7652411053273549, "gap": 20.75618622779257 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 160.46519124360202, -2.2452348338335923 ] ] }, { "type": "rectangle", "version": 440, "versionNonce": 819540565, "isDeleted": false, "id": "TbejdIYo_qNDw15yLP2IB", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 406.0812257713851, "y": 626.8305540252475, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1553965333, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 466, "versionNonce": 663477, "isDeleted": false, "id": "wfFvnFZuh0npL9hh0ez7o", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 932.383278655946, "y": 424.0107569968011, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 481.2890625, "height": 115, "seed": 781497973, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "lineHeight": 1.15, "baseline": 110 }, { "type": "arrow", "version": 464, "versionNonce": 734626075, "isDeleted": false, "id": "1ZbDRqbETCkEx62nCmnpJ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 741.0645380446722, "y": 492.31283255558515, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 178.4459423531871, "height": 83.08707392565111, "seed": 536879061, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "q4TKpiq2KAwPaz19GdhtK", "focus": -0.7697471991854113, "gap": 3.7450387249900814 }, "endBinding": { "elementId": "Vu0JJ6ZWuEhEyCfxeHPtc", "focus": -0.7822252364700005, "gap": 8.360835317635974 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 178.4459423531871, 83.08707392565111 ] ] }, { "type": "text", "version": 327, "versionNonce": 371646421, "isDeleted": false, "id": "Vu0JJ6ZWuEhEyCfxeHPtc", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 927.8713157154953, "y": 563.2132686484658, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 491.357421875, "height": 46, "seed": 385310005, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "lineHeight": 1.15, "baseline": 41 }, { "type": "text", "version": 91, "versionNonce": 1180085909, "isDeleted": false, "id": "0tGx2VdJLNf7W6HD76dtO", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 427.6895298601876, "y": 432.3583566254258, "strokeColor": "#9c36b5", "backgroundColor": "#a5d8ff", "width": 143.876953125, "height": 23, "seed": 450883221, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"build\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"build\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 338, "versionNonce": 957223925, "isDeleted": false, "id": "LQ2h2aO9uzDWyLG6OLn70", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.7251825950889, "y": 685.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 711939061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "8EqaPnZX2CgLaF08UNZZg" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 340, "versionNonce": 510774613, "isDeleted": false, "id": "8EqaPnZX2CgLaF08UNZZg", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 563.4175654075889, "y": 690.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1370164565, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "LQ2h2aO9uzDWyLG6OLn70", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 421, "versionNonce": 97999541, "isDeleted": false, "id": "St9t4nwHuXXVlmjDqfn_Z", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 488.62754764266447, "y": 731.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 2145950389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "DX10t075MMDu7BLtuUaij" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 417, "versionNonce": 2011446293, "isDeleted": false, "id": "DX10t075MMDu7BLtuUaij", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 580.4380945176645, "y": 736.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 500005909, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "St9t4nwHuXXVlmjDqfn_Z", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 475, "versionNonce": 1284370805, "isDeleted": false, "id": "XVGBz_X5yN6xjWTosVH2n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.04837438376217, "y": 779.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1666134389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "-xogFSFcP-Vv5cuOSFm8T" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 476, "versionNonce": 1092221653, "isDeleted": false, "id": "-xogFSFcP-Vv5cuOSFm8T", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 578.8589212587622, "y": 784.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1840462549, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "XVGBz_X5yN6xjWTosVH2n", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 125, "versionNonce": 1310578741, "isDeleted": false, "id": "N1a9yL7Pts16hUKY9-vhw", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 424.78852030984035, "y": 646.2446482189896, "strokeColor": "#be4bdb", "backgroundColor": "#a5d8ff", "width": 133.857421875, "height": 23, "seed": 361699381, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"test\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"test\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 184, "versionNonce": 2127603131, "isDeleted": false, "id": "O-YmtRLb8uFNqCAz22EoG", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 737.454940151797, "y": 535.9141784615474, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 190.41665096887027, "height": 112.96427727851824, "seed": 80234901, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.8392895251910331, "gap": 2.0300115262207328 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 190.41665096887027, 112.96427727851824 ] ] }, { "type": "arrow", "version": 327, "versionNonce": 780710651, "isDeleted": false, "id": "379hO6Dc5rygB38JgDbVo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 738.8084877231549, "y": 591.3526691276127, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 186.8066399682357, "height": 57.68023784868956, "seed": 211046133, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "2WwuMWX7YawqK0i1rDPJo", "focus": -0.5776522830934517, "gap": 2.1657966147995467 }, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.7269489945238884, "gap": 4.286474955497397 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 186.8066399682357, 57.68023784868956 ] ] }, { "type": "text", "version": 285, "versionNonce": 1165977685, "isDeleted": false, "id": "0TjxOfERekC91N3yciQIq", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 929.901602646888, "y": 632.4760859429873, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 518.076171875, "height": 46, "seed": 997763157, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "O-YmtRLb8uFNqCAz22EoG", "type": "arrow" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "lineHeight": 1.15, "baseline": 41 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/20-workflow-syntax.md ================================================ # Workflow syntax The Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status. :::note An exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run. ::: :::note We support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility. Read more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3) ::: Example steps: ```yaml steps: - name: backend image: golang commands: - go build - go test - name: frontend image: node commands: - npm install - npm run test - npm run build ``` In the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary. The name is optional, if not added the steps will be numerated. Another way to name a step is by using dictionaries: ```yaml steps: backend: image: golang commands: - go build - go test frontend: image: node commands: - npm install - npm run test - npm run build ``` ## Skip Commits Woodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive. ```bash git commit -m "updated README [CI SKIP]" ``` ## Steps Every step of your workflow executes commands inside a specified container.
The defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).
The associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` ### File changes are incremental - Woodpecker clones the source code in the beginning of the workflow - Changes to files are persisted through steps as the same volume is mounted to all steps ```yaml title=".woodpecker.yaml" steps: - name: build image: debian commands: - echo "test content" > myfile - name: a-test-step image: debian commands: - cat myfile ``` ### `image` Woodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers. When using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands. ```diff steps: - name: build + image: golang:1.6 commands: - go build - go test - name: publish + image: plugins/docker repo: foo/bar services: - name: database + image: mysql ``` Woodpecker supports any valid Docker image from any Docker registry: ```yaml image: golang image: golang:1.7 image: library/golang:1.7 image: index.docker.io/library/golang image: index.docker.io/library/golang:1.7 ``` Woodpecker does not automatically upgrade container images. Example configuration to always pull the latest image when updates are available: ```diff steps: - name: build image: golang:latest + pull: true ``` Learn more how you can use images from [different registries](./41-registries.md). ### `commands` Commands of every step are executed serially as if you would enter them into your local shell. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` There is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script: ```bash #!/bin/sh set -e go build go test ``` The above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed: ```bash docker run --entrypoint=build.sh golang ``` :::note Only build steps can define commands. You cannot use commands with plugins or services. ::: ### `entrypoint` Allows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `["/bin/sh", "-c"]`). If you define [`commands`](#commands), the default entrypoint will be `["/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"]`. You can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`. ### `environment` Woodpecker provides the ability to pass environment variables to individual steps. For more details, check the [environment docs](./50-environment.md). ### `secrets` Woodpecker provides the ability to store named parameters external to the YAML configuration file, in a central secret store. These secrets can be passed to individual steps of the workflow at runtime. For more details, check the [secrets docs](./40-secrets.md). ### `failure` Some of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow. ```diff steps: - name: backend image: golang commands: - go build - go test + failure: ignore ``` ### `when` - Conditional Execution Woodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ subconditions are true. A condition can be a check like: ```diff steps: - name: slack image: plugins/slack settings: channel: dev + when: + - event: pull_request + repo: test/test + - event: push + branch: main ``` The `slack` step is executed if one of these conditions is met: 1. The pipeline is executed from a pull request in the repo `test/test` 2. The pipeline is executed from a push to `maiǹ` #### `repo` Example conditional execution by repository: ```diff steps: - name: slack image: plugins/slack settings: channel: dev + when: + - repo: test/test ``` #### `branch` :::note Branch conditions are not applied to tags. ::: Example conditional execution by branch: ```diff steps: - name: slack image: plugins/slack settings: channel: dev + when: + - branch: main ``` > The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only. Execute a step if the branch is `main` or `develop`: ```yaml when: - branch: [main, develop] ``` Execute a step if the branch starts with `prefix/*`: ```yaml when: - branch: prefix/* ``` The branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples: - `*\\/*` to match patterns with exactly 1 `/` - `*\\/**` to match patters with at least 1 `/` - `*` to match patterns without `/` - `**` to match everything Execute a step using custom include and exclude logic: ```yaml when: - branch: include: [main, release/*] exclude: [release/1.0.0, release/1.1.*] ``` #### `event` Available events: `push`, `pull_request`, `pull_request_closed`, `tag`, `release`, `deployment`, `cron`, `manual` Execute a step if the build event is a `tag`: ```yaml when: - event: tag ``` Execute a step if the pipeline event is a `push` to a specified branch: ```diff when: - event: push + branch: main ``` Execute a step for multiple events: ```yaml when: - event: [push, tag, deployment] ``` #### `cron` This filter **only** applies to cron events and filters based on the name of a cron job. Make sure to have a `event: cron` condition in the `when`-filters as well. ```yaml when: - event: cron cron: sync_* # name of your cron job ``` [Read more about cron](./45-cron.md) #### `ref` The `ref` filter compares the git reference against which the workflow is executed. This allows you to filter, for example, tags that must start with **v**: ```yaml when: - event: tag ref: refs/tags/v* ``` #### `status` There are use cases for executing steps on failure, such as sending notifications for failed workflow / pipeline. Use the status constraint to execute steps even when the workflow fails: ```diff steps: - name: slack image: plugins/slack settings: channel: dev + when: + - status: [ success, failure ] ``` #### `platform` :::note This condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch. ::: Execute a step for a specific platform: ```yaml when: - platform: linux/amd64 ``` Execute a step for a specific platform using wildcards: ```yaml when: - platform: [linux/*, windows/amd64] ``` #### `matrix` Execute a step for a single matrix permutation: ```yaml when: - matrix: GO_VERSION: 1.5 REDIS_VERSION: 2.8 ``` #### `instance` Execute a step only on a certain Woodpecker instance matching the specified hostname: ```yaml when: - instance: stage.woodpecker.company.com ``` #### `path` :::info Path conditions are applied only to **push** and **pull_request** events. It is currently **only available** for GitHub, GitLab and Gitea (version 1.18.0 and newer) ::: Execute a step only on a pipeline with certain files being changed: ```yaml when: - path: 'src/*' ``` You can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`. For pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases. ```yaml when: - path: include: ['.woodpecker/*.yaml', '*.ini'] exclude: ['*.md', 'docs/**'] ignore_message: '[ALL]' on_empty: true ``` :::info Passing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting. ::: #### `evaluate` Execute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression. The expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library. Run on pushes to the default branch for the repository `owner/repo`: ```yaml when: - evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' ``` Run on commits created by user `woodpecker-ci`: ```yaml when: - evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' ``` Skip all commits containing `please ignore me` in the commit message: ```yaml when: - evaluate: 'not (CI_COMMIT_MESSAGE contains "please ignore me")' ``` Run on pull requests with the label `deploy`: ```yaml when: - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "deploy"' ``` Skip step only if `SKIP=true`, run otherwise or if undefined: ```yaml when: - evaluate: 'SKIP != "true"' ``` ### `depends_on` Normally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`: ```diff steps: - name: build # build will be executed immediately image: golang commands: - go build - name: deploy image: plugins/docker settings: repo: foo/bar + depends_on: [build, test] # deploy will be executed after build and test finished - name: test # test will be executed immediately as no dependencies are set image: golang commands: - go test ``` :::note You can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified. ```yaml steps: - name: check code format image: mstruebing/editorconfig-checker depends_on: [] # enable parallel steps ... ``` ::: ### `volumes` Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. For more details check the [volumes docs](./70-volumes.md). ### `detach` Woodpecker gives the ability to detach steps to run them in background until the workflow finishes. For more details check the [service docs](./60-services.md#detachment). ### `directory` Using `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run. ## `services` Woodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow. For more details check the [services docs](./60-services.md). ## `workspace` The workspace defines the shared volume and working directory shared by all workflow steps. The default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`). So an example would be `/woodpecker/src/github.com/octocat/hello-world`. The workspace can be customized using the workspace block in the YAML file: ```diff +workspace: + base: /go + path: src/github.com/octocat/hello-world steps: - name: build image: golang:latest commands: - go get - go test ``` :::note Plugins will always have the workspace base at `/woodpecker` ::: The base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps. ```diff workspace: + base: /go path: src/github.com/octocat/hello-world steps: - name: deps image: golang:latest commands: - go get - go test - name: build image: node:latest commands: - go build ``` This would be equivalent to the following docker commands: ```bash docker volume create my-named-volume docker run --volume=my-named-volume:/go golang:latest docker run --volume=my-named-volume:/go node:latest ``` The path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path. ```diff workspace: base: /go + path: src/github.com/octocat/hello-world ``` ```bash git clone https://github.com/octocat/hello-world \ /go/src/github.com/octocat/hello-world ``` ## `matrix` Woodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations. For more details check the [matrix build docs](./30-matrix-workflows.md). ## `labels` You can set labels for your workflow to select an agent to execute the workflow on. An agent will pick up and run a workflow when **every** label assigned to it matches the agents labels. To set additional agent labels, check the [agent configuration options](../30-administration/15-agent-config.md#woodpecker_filter_labels). Agents will have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of the agent backend) and `repo=*`. Agents can use a `*` as a wildcard for a label. For example `repo=*` will match every repo. Workflow labels with an empty value will be ignored. By default, each workflow has at least the `repo=your-user/your-repo-name` label. If you have set the [platform attribute](#platform) for your workflow it will have a label like `platform=your-os/your-arch` as well. You can add additional labels as a key value map: ```diff +labels: + location: europe # only agents with `location=europe` or `location=*` will be used + weather: sun + hostname: "" # this label will be ignored as it is empty steps: - name: build image: golang commands: - go build - go test ``` ### Filter by platform To configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key. Have a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`. Example: Assuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`. ```diff +labels: + platform: linux/arm64 steps: [...] ``` ## `variables` Woodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration. For more details and examples check the [Advanced usage docs](./90-advanced-usage.md) ## `clone` Woodpecker automatically configures a default clone step if not explicitly defined. When using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be on your `$PATH` for the default clone step to work. If not, you can still write a manual clone step. You can manually configure the clone step in your workflow for customization: ```diff +clone: + git: + image: woodpeckerci/plugin-git steps: - name: build image: golang commands: - go build - go test ``` Example configuration to override depth: ```diff clone: - name: git image: woodpeckerci/plugin-git + settings: + partial: false + depth: 50 ``` Example configuration to use a custom clone plugin: ```diff clone: - name: git + image: octocat/custom-git-plugin ``` Example configuration to clone Mercurial repository: ```diff clone: - name: hg + image: plugins/hg + settings: + path: bitbucket.org/foo/bar ``` ### Git Submodules To use the credentials that cloned the repository to clone it's submodules, update `.gitmodules` to use `https` instead of `git`: ```diff [submodule "my-module"] path = my-module -url = git@github.com:octocat/my-module.git +url = https://github.com/octocat/my-module.git ``` To use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`: ```diff clone: - name: git image: woodpeckerci/plugin-git settings: recursive: true + submodule_override: + my-module: https://github.com/octocat/my-module.git steps: ... ``` ## `skip_clone` By default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using: ```yaml skip_clone: true ``` ## `when` - Global workflow conditions Woodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue. For more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution). Example conditional execution by branch: ```diff +when: + branch: main + steps: - name: slack image: plugins/slack settings: channel: dev ``` The workflow now triggers on `main`, but also if the target branch of a pull request is `main`. ## `depends_on` Woodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword. ## `runs_on` Workflows that should run even on failure should set the `runs_on` tag. See [here](./25-workflows.md#flow-control) for an example. ## Privileged mode Woodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities. :::info Privileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker environment: - DOCKER_HOST=tcp://docker:2375 commands: - docker --tls=false ps services: - name: docker image: docker:dind commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false + privileged: true ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/25-workflows.md ================================================ # Workflows A pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps. In case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow. By placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored. You can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md). ## Benefits of using workflows - faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote - better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying - utilizing more agents to speed up the execution of the whole pipeline ## Example workflow definition :::warning Please note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow. If you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket). ::: ```bash .woodpecker/ ├── .build.yaml ├── .deploy.yaml ├── .lint.yaml └── .test.yaml ``` ```yaml title=".woodpecker/.build.yaml" steps: - name: build image: debian:stable-slim commands: - echo building - sleep 5 ``` ```yaml title=".woodpecker/.deploy.yaml" steps: - name: deploy image: debian:stable-slim commands: - echo deploying depends_on: - lint - build - test ``` ```yaml title=".woodpecker/.test.yaml" steps: - name: test image: debian:stable-slim commands: - echo testing - sleep 5 depends_on: - build ``` ```yaml title=".woodpecker/.lint.yaml" steps: - name: lint image: debian:stable-slim commands: - echo linting - sleep 5 ``` ## Status lines Each workflow will report its own status back to your forge. ## Flow control The workflows run in parallel on separate agents and share nothing. Dependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully. The name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`. ```diff steps: - name: deploy image: debian:stable-slim commands: - echo deploying +depends_on: + - lint + - build + - test ``` Workflows that need to run even on failures should set the `runs_on` tag. ```diff steps: - name: notify image: debian:stable-slim commands: - echo notifying depends_on: - deploy +runs_on: [ success, failure ] ``` :::info Some workflows don't need the source code, like creating a notification on failure. Read more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone) ::: ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/30-matrix-workflows.md ================================================ # Matrix workflows Woodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations. :::warning Woodpecker currently supports a maximum of **27 matrix axes** per workflow. If your matrix exceeds this number, any additional axes will be silently ignored. ::: Example matrix definition: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 REDIS_VERSION: - 2.6 - 2.8 - 3.0 ``` Example matrix definition containing only specific combinations: ```yaml matrix: include: - GO_VERSION: 1.4 REDIS_VERSION: 2.8 - GO_VERSION: 1.5 REDIS_VERSION: 2.8 - GO_VERSION: 1.6 REDIS_VERSION: 3.0 ``` ## Interpolation Matrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 DATABASE: - mysql:8 - mysql:5 - mariadb:10.1 steps: - name: build image: golang:${GO_VERSION} commands: - go get - go build - go test services: - name: database image: ${DATABASE} ``` Example YAML file after injecting the matrix parameters: ```diff steps: - name: build - image: golang:${GO_VERSION} + image: golang:1.4 commands: - go get - go build - go test + environment: + - GO_VERSION=1.4 + - DATABASE=mysql:8 services: - name: database - image: ${DATABASE} + image: mysql:8 ``` ## Examples ### Example matrix pipeline based on Docker image tag ```yaml matrix: TAG: - 1.7 - 1.8 - latest steps: - name: build image: golang:${TAG} commands: - go build - go test ``` ### Example matrix pipeline based on container image ```yaml matrix: IMAGE: - golang:1.7 - golang:1.8 - golang:latest steps: - name: build image: ${IMAGE} commands: - go build - go test ``` ### Example matrix pipeline using multiple platforms ```yaml matrix: platform: - linux/amd64 - linux/arm64 labels: platform: ${platform} steps: - name: test image: alpine commands: - echo "I am running on ${platform}" - name: test-arm-only image: alpine commands: - echo "I am running on ${platform}" - echo "Arm is cool!" when: platform: linux/arm* ``` :::note If you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/22-backends/40-kubernetes.md#node-selector). ::: ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/40-secrets.md ================================================ # Secrets Woodpecker provides the ability to store named parameters external to the YAML configuration file, in a central secret store. These secrets can be passed to individual steps of the pipeline at runtime. Woodpecker provides three different levels to add secrets to your pipeline. The following list shows the priority of the different levels. If a secret is defined in multiple levels, will be used following this priorities: Repository secrets > Organization secrets > Global secrets. 1. **Repository secrets**: They are available to all pipelines of an repository. 2. **Organization secrets**: They are available to all pipelines of an organization. 3. **Global secrets**: Can be configured by an instance admin. They are available to all pipelines of the **whole** Woodpecker instance and should therefore **only** be used for secrets that are allowed to be read by **all** users. ## Usage ### Use secrets in commands :::warning The use of secrets is deprecated as of version 2.8 and planned to be removed with version 3. Instead, you can use the _secrets in settings and environment_ approach outlined below. You can already migrate to this strategy with version 2.8. ::: Secrets are exposed to your pipeline steps and plugins as uppercase environment variables and can therefore be referenced in the commands section of your pipeline, once their usage is declared in the `secrets` section: ```diff steps: - name: docker image: docker commands: + - echo $docker_username + - echo $DOCKER_PASSWORD + secrets: [ docker_username, DOCKER_PASSWORD ] ``` The case of the environment variables is not changed, but secret matching is done case-insensitively. In the example above, `DOCKER_PASSWORD` would also match if the secret is called `docker_password`. ### Use secrets in settings and environment You can set a setting or environment value from secrets using the `from_secret` syntax. The example below passes a secret called `secret_token` as an environment variable that will be called `TOKEN_ENV`: ```diff steps: env-secret-example: image: alpine commands: + - echo "The secret is $TOKEN_ENV" + environment: + TOKEN_ENV: + from_secret: secret_token ``` You can use the same syntax to pass secrets to settings. For example, you can pass a secret named `secret_token` to the settings called `token`, which will then be available in the plugin as environment variable named `PLUGIN_TOKEN` (See [plugins](./51-plugins/20-creating-plugins.md#settings) for details). ```diff steps: - name: settings-secret-example image: my-plugin + settings: + token: + from_secret: secret_token ``` ### Note about parameter pre-processing Please note parameter expressions are subject to pre-processing. When using secrets in parameter expressions they should be escaped. ```diff steps: - name: docker image: docker commands: - - echo ${docker_username} - - echo ${DOCKER_PASSWORD} + - echo $${docker_username} + - echo $${DOCKER_PASSWORD} secrets: [ docker_username, DOCKER_PASSWORD ] ``` ### Use in Pull Requests events Secrets are not exposed to pull requests by default. You can override this behavior by creating the secret and enabling the `pull_request` event type, either in UI or by CLI, see below. :::note Please be careful when exposing secrets to pull requests. If your repository is open source and accepts pull requests your secrets are not safe. A bad actor can submit a malicious pull request that exposes your secrets. ::: ## Image filter To prevent abusing your secrets from malicious usage, you can limit a secret to a list of images. If enabled they are not available to any other plugin (steps without user-defined commands). If you or an attacker defines explicit commands, the secrets will not be available to the container to prevent leaking them. ## Adding Secrets Secrets are added to the Woodpecker in the UI or with the CLI. ### CLI Examples Create the secret using default settings. The secret will be available to all images in your pipeline, and will be available to all push, tag, and deployment events (not pull request events). ```bash woodpecker-cli secret add \ -repository octocat/hello-world \ -name aws_access_key_id \ -value ``` Create the secret and limit to a single image: ```diff woodpecker-cli secret add \ -repository octocat/hello-world \ + -image plugins/s3 \ -name aws_access_key_id \ -value ``` Create the secrets and limit to a set of images: ```diff woodpecker-cli secret add \ -repository octocat/hello-world \ + -image plugins/s3 \ + -image peloton/woodpecker-ecs \ -name aws_access_key_id \ -value ``` Create the secret and enable for multiple hook events: ```diff woodpecker-cli secret add \ -repository octocat/hello-world \ -image plugins/s3 \ + -event pull_request \ + -event push \ + -event tag \ -name aws_access_key_id \ -value ``` Loading secrets from file using curl `@` syntax. This is the recommended approach for loading secrets from file to preserve newlines: ```diff woodpecker-cli secret add \ -repository octocat/hello-world \ -name ssh_key \ + -value @/root/ssh/id_rsa ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/41-registries.md ================================================ # Registries Woodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries. ## Images from private registries You must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file. These credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin. Example configuration using a private image: ```diff steps: - name: build + image: gcr.io/custom/golang commands: - go build - go test ``` Woodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers. Example registry hostnames: - Image `gcr.io/foo/bar` has hostname `gcr.io` - Image `foo/bar` has hostname `docker.io` - Image `qux.com:8000/foo/bar` has hostname `qux.com:8000` Example registry hostname matching logic: - Hostname `gcr.io` matches image `gcr.io/foo/bar` - Hostname `docker.io` matches `golang` - Hostname `docker.io` matches `library/golang` - Hostname `docker.io` matches `bradyrydzewski/golang` - Hostname `docker.io` matches `bradyrydzewski/golang:latest` :::note The flow above doesn't work in Kubernetes. There is [workaround](../30-administration/22-backends/40-kubernetes.md#images-from-private-registries). ::: ## Global registry support To make a private registry globally available, check the [server configuration docs](../30-administration/10-server-config.md#global-registry-setting). ## GCR registry support For specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file). ## Local Images :::warning For this, privileged rights are needed only available to admins. In addition, this only works when using a single agent. ::: It's possible to build a local image by mounting the docker socket as a volume. With a `Dockerfile` at the root of the project: ```yaml steps: - name: build-image image: docker commands: - docker build --rm -t local/project-image . volumes: - /var/run/docker.sock:/var/run/docker.sock - name: build-project image: local/project-image commands: - ./build.sh ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/45-cron.md ================================================ # Cron To configure cron jobs you need at least push access to the repository. ## Add a new cron job 1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job: ```diff steps: - name: sync_locales image: weblate_sync settings: url: example.com token: from_secret: weblate_token + when: + event: cron + cron: "name of the cron job" # if you only want to execute this step by a specific cron job ``` 2. Create a new cron job in the repository settings: ![cron settings](./cron-settings.png) The supported schedule syntax can be found at . If you need general understanding of the cron syntax is a good place to start and experiment. Examples: `@every 5m`, `@daily`, `0 30 * * * *` ... :::info Woodpeckers cron syntax starts with seconds instead of minutes as used by most linux cron schedulers. Example: "At minute 30 every hour" would be `0 30 * * * *` instead of `30 * * * *` ::: ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/50-environment.md ================================================ # Environment variables Woodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables: ```diff steps: - name: build image: golang + environment: + CGO: 0 + GOOS: linux + GOARCH: amd64 commands: - go build - go test ``` Please note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section. ```diff steps: - name: build image: golang - environment: - - PATH=$PATH:/go commands: + - export PATH=$PATH:/go - go build - go test ``` :::warning `${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped: ::: ```diff steps: - name: build image: golang commands: - - export PATH=${PATH}:/go + - export PATH=$${PATH}:/go - go build - go test ``` ## Built-in environment variables This is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime. | NAME | Description | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `CI` | CI environment name (value: `woodpecker`) | | | **Repository** | | `CI_REPO` | repository full name `/` | | `CI_REPO_OWNER` | repository owner | | `CI_REPO_NAME` | repository name | | `CI_REPO_REMOTE_ID` | repository remote ID, is the UID it has in the forge | | `CI_REPO_SCM` | repository SCM (git) | | `CI_REPO_URL` | repository web URL | | `CI_REPO_CLONE_URL` | repository clone URL | | `CI_REPO_CLONE_SSH_URL` | repository SSH clone URL | | `CI_REPO_DEFAULT_BRANCH` | repository default branch (main) | | `CI_REPO_PRIVATE` | repository is private | | `CI_REPO_TRUSTED` | repository is trusted | | | **Current Commit** | | `CI_COMMIT_SHA` | commit SHA | | `CI_COMMIT_REF` | commit ref | | `CI_COMMIT_REFSPEC` | commit ref spec | | `CI_COMMIT_BRANCH` | commit branch (equals target branch for pull requests) | | `CI_COMMIT_SOURCE_BRANCH` | commit source branch (empty if event is not `pull_request` or `pull_request_closed`) | | `CI_COMMIT_TARGET_BRANCH` | commit target branch (empty if event is not `pull_request` or `pull_request_closed`) | | `CI_COMMIT_TAG` | commit tag name (empty if event is not `tag`) | | `CI_COMMIT_PULL_REQUEST` | commit pull request number (empty if event is not `pull_request` or `pull_request_closed`) | | `CI_COMMIT_PULL_REQUEST_LABELS` | labels assigned to pull request (empty if event is not `pull_request` or `pull_request_closed`) | | `CI_COMMIT_MESSAGE` | commit message | | `CI_COMMIT_AUTHOR` | commit author username | | `CI_COMMIT_AUTHOR_EMAIL` | commit author email address | | `CI_COMMIT_AUTHOR_AVATAR` | commit author avatar | | `CI_COMMIT_PRERELEASE` | release is a pre-release (empty if event is not `release`) | | | **Current pipeline** | | `CI_PIPELINE_NUMBER` | pipeline number | | `CI_PIPELINE_PARENT` | number of parent pipeline | | `CI_PIPELINE_EVENT` | pipeline event (see [pipeline events](../20-usage/15-terminology/index.md#pipeline-events)) | | `CI_PIPELINE_URL` | link to the web UI for the pipeline | | `CI_PIPELINE_FORGE_URL` | link to the forge's web UI for the commit(s) or tag that triggered the pipeline | | `CI_PIPELINE_DEPLOY_TARGET` | pipeline deploy target for `deployment` events (i.e. production) | | `CI_PIPELINE_DEPLOY_TASK` | pipeline deploy task for `deployment` events (i.e. migration) | | `CI_PIPELINE_STATUS` | pipeline status (success, failure) | | `CI_PIPELINE_CREATED` | pipeline created UNIX timestamp | | `CI_PIPELINE_STARTED` | pipeline started UNIX timestamp | | `CI_PIPELINE_FINISHED` | pipeline finished UNIX timestamp | | `CI_PIPELINE_FILES` | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | | | **Current workflow** | | `CI_WORKFLOW_NAME` | workflow name | | | **Current step** | | `CI_STEP_NAME` | step name | | `CI_STEP_NUMBER` | step number | | `CI_STEP_STATUS` | step status (success, failure) | | `CI_STEP_STARTED` | step started UNIX timestamp | | `CI_STEP_FINISHED` | step finished UNIX timestamp | | `CI_STEP_URL` | URL to step in UI | | | **Previous commit** | | `CI_PREV_COMMIT_SHA` | previous commit SHA | | `CI_PREV_COMMIT_REF` | previous commit ref | | `CI_PREV_COMMIT_REFSPEC` | previous commit ref spec | | `CI_PREV_COMMIT_BRANCH` | previous commit branch | | `CI_PREV_COMMIT_SOURCE_BRANCH` | previous commit source branch | | `CI_PREV_COMMIT_TARGET_BRANCH` | previous commit target branch | | `CI_PREV_COMMIT_URL` | previous commit link in forge | | `CI_PREV_COMMIT_MESSAGE` | previous commit message | | `CI_PREV_COMMIT_AUTHOR` | previous commit author username | | `CI_PREV_COMMIT_AUTHOR_EMAIL` | previous commit author email address | | `CI_PREV_COMMIT_AUTHOR_AVATAR` | previous commit author avatar | | | **Previous pipeline** | | `CI_PREV_PIPELINE_NUMBER` | previous pipeline number | | `CI_PREV_PIPELINE_PARENT` | previous pipeline number of parent pipeline | | `CI_PREV_PIPELINE_EVENT` | previous pipeline event (see [pipeline events](../20-usage/15-terminology/index.md#pipeline-events)) | | `CI_PREV_PIPELINE_URL` | previous pipeline link in CI | | `CI_PREV_PIPELINE_FORGE_URL` | previous pipeline link to event in forge | | `CI_PREV_PIPELINE_DEPLOY_TARGET` | previous pipeline deploy target for `deployment` events (ie production) | | `CI_PREV_PIPELINE_DEPLOY_TASK` | previous pipeline deploy task for `deployment` events (ie migration) | | `CI_PREV_PIPELINE_STATUS` | previous pipeline status (success, failure) | | `CI_PREV_PIPELINE_CREATED` | previous pipeline created UNIX timestamp | | `CI_PREV_PIPELINE_STARTED` | previous pipeline started UNIX timestamp | | `CI_PREV_PIPELINE_FINISHED` | previous pipeline finished UNIX timestamp | | |   | | `CI_WORKSPACE` | Path of the workspace where source code gets cloned to | | | **System** | | `CI_SYSTEM_NAME` | name of the CI system: `woodpecker` | | `CI_SYSTEM_URL` | link to CI system | | `CI_SYSTEM_HOST` | hostname of CI server | | `CI_SYSTEM_VERSION` | version of the server | | | **Forge** | | `CI_FORGE_TYPE` | name of forge (gitea, github, ...) | | `CI_FORGE_URL` | root URL of configured forge | | | **Internal** - Please don't use! | | `CI_SCRIPT` | Internal script path. Used to call pipeline step commands. | | `CI_NETRC_USERNAME` | Credentials for private repos to be able to clone data. (Only available for specific images) | | `CI_NETRC_PASSWORD` | Credentials for private repos to be able to clone data. (Only available for specific images) | | `CI_NETRC_MACHINE` | Credentials for private repos to be able to clone data. (Only available for specific images) | ## Global environment variables If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` These can be used, for example, to manage the image tag used by multiple projects. ```ini WOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18 ``` ```diff steps: - name: build - image: golang:1.18 + image: golang:${GOLANG_VERSION} commands: - [...] ``` ## String Substitution Woodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration. Example commit substitution: ```diff steps: - name: docker image: plugins/docker settings: + tags: ${CI_COMMIT_SHA} ``` Example tag substitution: ```diff steps: - name: docker image: plugins/docker settings: + tags: ${CI_COMMIT_TAG} ``` ## String Operations Woodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values. | OPERATION | DESCRIPTION | | ------------------ | ------------------------------------------------ | | `${param}` | parameter substitution | | `${param,}` | parameter substitution with lowercase first char | | `${param,,}` | parameter substitution with lowercase | | `${param^}` | parameter substitution with uppercase first char | | `${param^^}` | parameter substitution with uppercase | | `${param:pos}` | parameter substitution with substring | | `${param:pos:len}` | parameter substitution with substring and length | | `${param=default}` | parameter substitution with default | | `${param##prefix}` | parameter substitution with prefix removal | | `${param%%suffix}` | parameter substitution with suffix removal | | `${param/old/new}` | parameter substitution with find and replace | Example variable substitution with substring: ```diff steps: - name: docker image: plugins/docker settings: + tags: ${CI_COMMIT_SHA:0:8} ``` Example variable substitution strips `v` prefix from `v.1.0.0`: ```diff steps: - name: docker image: plugins/docker settings: + tags: ${CI_COMMIT_TAG##v} ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/51-plugins/20-creating-plugins.md ================================================ # Creating plugins Creating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT. ## Settings To allow users to configure the behavior of your plugin, you should use `settings:`. These are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix. Using a setting like `url` results in an env var named `PLUGIN_URL`. Characters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`. CamelCase is not respected, `anInt` get `PLUGIN_ANINT`. ### Basic settings Using any basic YAML type (scalar) will be converted into a string: | Setting | Environment value | | -------------------- | ---------------------------- | | `some-bool: false` | `PLUGIN_SOME_BOOL="false"` | | `some_String: hello` | `PLUGIN_SOME_STRING="hello"` | | `anInt: 3` | `PLUGIN_ANINT="3"` | ### Complex settings It's also possible to use complex settings like this: ```yaml steps: - name: plugin image: foo/plugin settings: complex: abc: 2 list: - 2 - 3 ``` Values like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{"abc": "2", "list": [ "2", "3" ]}`. ### Secrets Secrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#use-secrets-in-settings-and-environment). ## Plugin library For Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See . ## Metadata In your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins). Supported metadata: - `name`: The plugin's full name - `icon`: URL to your plugin's icon - `description`: A short description of what it's doing - `author`: Your name - `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin) - `containerImage`: name of the container image - `containerImageUrl`: link to the container image - `url`: homepage or repository of your plugin If you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required. ## Example plugin This provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline. ### What end users will see The below example demonstrates how we might configure a webhook plugin in the YAML file: ```yaml steps: - name: webhook image: foo/webhook settings: url: https://example.com method: post body: | hello world ``` ### Write the logic Create a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`. ```bash #!/bin/sh curl \ -X ${PLUGIN_METHOD} \ -d ${PLUGIN_BODY} \ ${PLUGIN_URL} ``` ### Package it Create a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint. ```dockerfile # please pin the version, e.g. alpine:3.19 FROM alpine ADD script.sh /bin/ RUN chmod +x /bin/script.sh RUN apk -Uuv add curl ca-certificates ENTRYPOINT /bin/script.sh ``` Build and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community. ```shell docker build -t foo/webhook . docker push foo/webhook ``` Execute your plugin locally from the command line to verify it is working: ```shell docker run --rm \ -e PLUGIN_METHOD=post \ -e PLUGIN_URL=https://example.com \ -e PLUGIN_BODY="hello world" \ foo/webhook ``` ## Best practices - Build your plugin for different architectures to allow many users to use them. At least, you should support `amd64` and `arm64`. - Provide binaries for users using the `local` backend. These should also be built for different OS/architectures. - Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible. - Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names. - Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)). - Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)). ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/51-plugins/51-overview.md ================================================ # Plugins Plugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline. Plugins can be used to deploy code, publish artifacts, send notification, and more. They are automatically pulled from the default container registry the agent's have configured. ```dockerfile title="Dockerfile" FROM laszlocloud/kubectl COPY deploy /usr/local/deploy ENTRYPOINT ["/usr/local/deploy"] ``` ```bash title="deploy" kubectl apply -f $PLUGIN_TEMPLATE ``` ```yaml title=".woodpecker.yaml" steps: - name: deploy-to-k8s image: laszlocloud/my-k8s-plugin settings: template: config/k8s/service.yaml ``` Example pipeline using the Docker and Slack plugins: ```yaml steps: - name: build image: golang commands: - go build - go test - name: publish image: plugins/docker settings: repo: foo/bar tags: latest - name: notify image: plugins/slack settings: channel: dev ``` ## Plugin Isolation Plugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree. While normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author. That's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically adjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands` or `entrypoint` which will fail. Using `secrets` or `environment` is possible, but in this case, the plugin is internally not treated as plugin anymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition. ## Finding Plugins For official plugins, you can use the Woodpecker plugin index: - [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins) :::tip There are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking. - [Drone Plugins](http://plugins.drone.io) - [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/) ::: ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/51-plugins/_category_.yaml ================================================ label: 'Plugins' # position: 2 collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/60-services.md ================================================ # Services Woodpecker provides a services section in the YAML file used for defining service containers. The below configuration composes database and cache containers. Services are accessed using custom hostnames. In the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`. ```yaml steps: - name: build image: golang commands: - go build - go test services: - name: database image: mysql - name: cache image: redis ``` You can define a port and a protocol explicitly: ```yaml services: - name: database image: mysql ports: - 3306 - name: wireguard image: wg ports: - 51820/udp ``` ## Configuration Service containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more. ```diff services: - name: database image: mysql + environment: + - MYSQL_DATABASE=test + - MYSQL_ALLOW_EMPTY_PASSWORD=yes - name: cache image: redis ``` ## Detachment Service and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required. ```diff steps: - name: build image: golang commands: - go build - go test - name: database image: redis + detach: true - name: test image: golang commands: - go test ``` Containers from detached steps will terminate when the pipeline ends. ## Initialization Service containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff. ```diff steps: - name: test image: golang commands: + - sleep 15 - go get - go test services: - name: database image: mysql ``` ## Complete Pipeline Example ```yaml services: - name: database image: mysql environment: - MYSQL_DATABASE=test - MYSQL_ROOT_PASSWORD=example steps: - name: get-version image: ubuntu commands: - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null - sleep 30s # need to wait for mysql-server init - echo 'SHOW VARIABLES LIKE "version"' | mysql -uroot -hdatabase test -pexample ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/70-volumes.md ================================================ # Volumes Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. :::note Volumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker commands: - docker build --rm -t octocat/hello-world . - docker run --rm octocat/hello-world --test - docker push octocat/hello-world - docker rmi octocat/hello-world volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` Please note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error. ```diff -volumes: [ ./certs:/etc/ssl/certs ] +volumes: [ /etc/ssl/certs:/etc/ssl/certs ] ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/72-linter.md ================================================ # Linter Woodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines. ![errors and warnings in UI](./linter-warnings-errors.png) ## Running the linter from CLI You can run the linter also manually from the CLI: ```shell woodpecker-cli lint ``` ## Bad habit warnings Woodpecker warns you if your configuration contains some bad habits. ### Event filter for all steps All your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well. Examples of an **incorrect** config for this rule: ```yaml when: - branch: main - event: tag ``` This will trigger the warning because the first item (`branch: main`) does not filter with an event. ```yaml steps: - name: test when: branch: main - name: deploy when: event: tag ``` Examples of a **correct** config for this rule: ```yaml when: - branch: main event: push - event: tag ``` ```yaml steps: - name: test when: event: [tag, push] - name: deploy when: - event: tag ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/75-project-settings.md ================================================ # Project settings As the owner of a project in Woodpecker you can change project related settings via the web interface. ![project settings](./project-settings.png) ## Pipeline path The path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`. ## Repository hooks Your Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting. ## Allow pull requests Enables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests. ## Allow deployments Enables a pipeline to be started with the `deploy` event from a successful pipeline. :::danger Only activate this option if you trust all users who have push access to your repository. Otherwise, these users will be able to steal secrets that are only available for `deploy` events. ::: ## Require approval for To prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `All pull requests`. ## Trusted If you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes. :::note Only server admins can set this option. If you are not a server admin this option won't be shown in your project settings. ::: ## Only inject netrc credentials into trusted containers Cloning pipeline step may need git credentials. They are injected via netrc. By default, they're only injected if this option is enabled, the repo is trusted ([see above](#trusted)) or the image is a trusted clone image. If you uncheck the option, git credentials will be injected into any container in clone step. ## Project visibility You can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners. - `Public` Every user can see your project without being logged in. - `Internal` Only authenticated users of the Woodpecker instance can see this project. - `Private` Only you and other owners of the repository can see this project. ## Timeout After this timeout a pipeline has to finish or will be treated as timed out. ## Cancel previous pipelines By enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one. ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/80-badges.md ================================================ # Status Badges Woodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code. ## Badge endpoint ```uri :///api/badges//status.svg ``` The status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter. ```diff -:///api/badges//status.svg +:///api/badges//status.svg?branch= ``` Please note status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/90-advanced-usage.md ================================================ # Advanced usage ## Advanced YAML syntax YAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config: ### Anchors & aliases You can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config. To convert this: ```yaml steps: - name: test image: golang:1.18 commands: go test ./... - name: build image: golang:1.18 commands: build ``` Just add a new section called **variables** like this: ```diff +variables: + - &golang_image 'golang:1.18' steps: - name: test - image: golang:1.18 + image: *golang_image commands: go test ./... - name: build - image: golang:1.18 + image: *golang_image commands: build ``` ### Map merges and overwrites ```yaml variables: - &base-plugin-settings target: dist recursive: false try: true - &special-setting special: true - &some-plugin codeberg.org/6543/docker-images/print_env steps: - name: develop image: *some-plugin settings: <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map when: branch: develop - name: main image: *some-plugin settings: <<: *base-plugin-settings # merge one map and ... try: false # ... overwrite original value ongoing: false # ... adding a new value when: branch: main ``` ### Sequence merges ```yaml variables: pre_cmds: &pre_cmds - echo start - whoami post_cmds: &post_cmds - echo stop hello_cmd: &hello_cmd - echo hello steps: - name: step1 image: debian commands: - <<: *pre_cmds # prepend a sequence - echo exec step now do dedicated things - <<: *post_cmds # append a sequence - name: step2 image: debian commands: - <<: [*pre_cmds, *hello_cmd] # prepend two sequences - echo echo from second step - <<: *post_cmds ``` ### References - [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) - [YAML Cheatsheet](https://learnxinyminutes.com/docs/yaml) ## Persisting environment data between steps One can create a file containing environment variables, and then source it in each step that needs them. ```yaml steps: - name: init image: bash commands: - echo "FOO=hello" >> envvars - echo "BAR=world" >> envvars - name: debug image: bash commands: - source ./envvars - echo $FOO ``` ## Declaring global variables As described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables: ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` Note that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps. ## Docker in docker (dind) setup :::warning This set up will only work on trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: The snippet below shows how a step can communicate with the docker daemon via a `docker:dind` service. :::note If your aim ist to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead. ::: First we need to define a servie running a docker with the `dind` tag. This service must run in privileged mode: ```yaml services: - name: docker image: docker:27.4-dind privileged: true ports: - 2376 ``` Next we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (since Unauthenticated TCP connections have been deprecated [as of docker v26](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will ve removed in release v28). We can achieve this by letting the daemon generate TLS certificates for us and share them with the client via a volume mount in the agent (`/opt/woodpeckerci/dind-certs` in the example below). ```diff services: - name: docker image: docker:27.4-dind privileged: true + environment: + DOCKER_TLS_CERTDIR: /dind-certs + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` In the step that needs access to the daemon we need to: 1. Set the `DOCKER_*` environment variables shown below, setting up the connection with the daemon. These are standardized environment variables that should work with the docker client used by your framework of choice (e.g. [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) or similar). 2. Mount the volume where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`) In this example we test the connection with the vanilla docker client: ```diff steps: - name: test image: docker:27.4-cli + environment: + DOCKER_HOST: "tcp://docker:2376" + DOCKER_CERT_PATH: "/dind-certs/client"27.4-cli + DOCKER_TLS_VERIFY: "1" + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version ``` This step should output version information of the client and the server if everything has been set correctly. Complete example: ```yaml steps: - name: test image: docker:27.4-cli environment: DOCKER_HOST: "tcp://docker:2376" DOCKER_CERT_PATH: "/dind-certs/client"27.4-cli DOCKER_TLS_VERIFY: "1" volumes: - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version services: - name: docker image: docker:27.4-dind privileged: true environment: DOCKER_TLS_CERTDIR: /dind-certs volumes: - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` ================================================ FILE: docs/versioned_docs/version-2.8/20-usage/_category_.yaml ================================================ label: 'Usage' # position: 2 collapsible: true collapsed: false ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/00-getting-started.md ================================================ # Getting started A Woodpecker deployment consists of two parts: - A server which is the heart of Woodpecker and ships the web interface. - Next to one server, you can deploy any number of agents which will run the pipelines. Each agent is able to process one [workflow](../20-usage/15-terminology/index.md) by default. If you have 4 agents installed and connected to the Woodpecker server, your system will process four workflows (not pipelines) in parallel. :::tip You can add more agents to increase the number of parallel workflows or set the agent's `WOODPECKER_MAX_WORKFLOWS=1` environment variable to increase the number of parallel workflows per agent. ::: ## Which version of Woodpecker should I use? Woodpecker is having two different kinds of releases: **stable** and **next**. Find more information about the different versions [here](/versions). ## Hardware Requirements Below are minimal resources requirements for Woodpecker components itself: | Component | Memory | CPU | | --------- | ------ | --- | | Server | 200 MB | 1 | | Agent | 32 MB | 1 | Note, that those values do not include the operating system or workload (pipelines execution) resource consumption. In addition you need at least some kind of database which requires additional resources depending on the selected database system. ## Installation You can install Woodpecker on multiple ways. If you are not sure which one to choose, we recommend using the [docker-compose](./05-deployment-methods/10-docker-compose.md) method for the beginning: - Using [docker-compose](./05-deployment-methods/10-docker-compose.md) with the official [container images](./05-deployment-methods/10-docker-compose.md#docker-images) - Using [Kubernetes](./05-deployment-methods/20-kubernetes.md) via the Woodpecker Helm chart - Using binaries, DEBs or RPMs you can download from [latest release](https://github.com/woodpecker-ci/woodpecker/releases/latest) - Or using a [third-party installation method](./05-deployment-methods/30-third-party.md) ## Database By default Woodpecker uses a SQLite database which requires zero installation or configuration. See the [database settings](./10-database.md) page if you want to use a different database system like MySQL or PostgreSQL. ## Forge What would be a CI/CD system without any code? By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md) like GitHub or Gitea you can start running pipelines on events like pushes or pull requests. Woodpecker will also use your forge for authentication and to report back the status of your pipelines. See the [forge settings](./11-forges/11-overview.md) to connect it to Woodpecker. ## Configuration Check the [server configuration](./10-server-config.md) and [agent configuration](./15-agent-config.md) pages to see if you need to adjust any additional parts and after that you should be ready to start with [your first pipeline](../20-usage/10-intro.md). ## Agent The agent is the worker which executes the [workflows](../20-usage/15-terminology/index.md). Woodpecker agents can execute work using a [backend](../20-usage/15-terminology/index.md) like [docker](./22-backends/10-docker.md) or [kubernetes](./22-backends/40-kubernetes.md). By default if you choose to deploy an agent using [docker-compose](./05-deployment-methods/10-docker-compose.md) the agent simply use docker for the backend as well. So nothing to worry about here. If you still prefer to adjust the agent to your needs, check the [agent configuration](./15-agent-config.md) page. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/10-docker-compose.md ================================================ # docker-compose The below [docker-compose](https://docs.docker.com/compose/) configuration can be used to start a Woodpecker server with a single agent. It relies on a number of environment variables that you must set before running `docker-compose up`. The variables are described below. ```yaml title="docker-compose.yaml" version: '3' services: woodpecker-server: image: woodpeckerci/woodpecker-server:latest ports: - 8000:8000 volumes: - woodpecker-server-data:/var/lib/woodpecker/ environment: - WOODPECKER_OPEN=true - WOODPECKER_HOST=${WOODPECKER_HOST} - WOODPECKER_GITHUB=true - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: image: woodpeckerci/woodpecker-agent:latest command: agent restart: always depends_on: - woodpecker-server volumes: - woodpecker-agent-config:/etc/woodpecker - /var/run/docker.sock:/var/run/docker.sock environment: - WOODPECKER_SERVER=woodpecker-server:9000 - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} volumes: woodpecker-server-data: woodpecker-agent-config: ``` Woodpecker needs to know its own address. You must therefore provide the public address of it in `://` format. Please omit trailing slashes: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_HOST=${WOODPECKER_HOST} ``` Woodpecker can also have its port's configured. It uses a separate port for gRPC and for HTTP. The agent performs gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR} + - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR} ``` Reverse proxying can also be [configured for gRPC](../40-advanced/10-proxy.md#caddy). If the agents are connecting over the internet, it should also be SSL encrypted. The agent then needs to be configured to be secure: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_GRPC_SECURE=true # defaults to false + - WOODPECKER_GRPC_VERIFY=true # default ``` As agents run pipeline steps as docker containers they require access to the host machine's Docker daemon: ```diff title="docker-compose.yaml" version: '3' services: [...] woodpecker-agent: [...] + volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` Agents require the server address for agent-to-server communication. The agent connects to the server's gRPC port: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-agent: [...] environment: + - WOODPECKER_SERVER=woodpecker-server:9000 ``` The server and agents use a shared secret to authenticate communication. This should be a random string of your choosing and should be kept private. You can generate such string with `openssl rand -hex 32`: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Docker images Image variants: - The `latest` image is the latest stable release - The `vX.X.X` images are stable releases - The `vX.X` images are based on the current release branch (e.g. `release/v1.0`) and can be used to get bugfixes asap - The `next` images are based on the current `main` branch ```bash # server docker pull woodpeckerci/woodpecker-server:latest docker pull woodpeckerci/woodpecker-server:latest-alpine # agent docker pull woodpeckerci/woodpecker-agent:latest docker pull woodpeckerci/woodpecker-agent:latest-alpine # cli docker pull woodpeckerci/woodpecker-cli:latest docker pull woodpeckerci/woodpecker-cli:latest-alpine ``` ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/20-kubernetes.md ================================================ # Kubernetes We recommended to deploy Woodpecker using the [Woodpecker helm chart](https://github.com/woodpecker-ci/helm). Have a look at the [`values.yaml`](https://github.com/woodpecker-ci/helm/blob/main/charts/woodpecker/values.yaml) config files for all available settings. The chart contains two subcharts, `server` and `agent` which are automatically configured as needed. The chart started off with two independent charts but was merged into one to simplify the deployment at start of 2023. A couple of backend-specific config env vars exists which are described in the [kubernetes backend docs](../22-backends/40-kubernetes.md). ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/30-third-party.md ================================================ # Third-party installation methods :::info These installation methods are not officially supported. If you experience issues with them, please open issues in the specific repositories. ::: - [Using NixOS](./40-nixos.md) via the [NixOS module](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker) - [On Alpine Edge](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=) - [On Arch Linux](https://archlinux.org/packages/?q=woodpecker) - [On openSUSE](https://software.opensuse.org/package/woodpecker) - [Using YunoHost](https://apps.yunohost.org/app/woodpecker) - [On Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html) ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/40-nixos.md ================================================ # NixOS :::info Note that this module is not maintained by the Woodpecker developers. If you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained. ::: The NixOS install is in theory quite similar to the binary install and supports multiple backends. In practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken. ## General Configuration ```nix { config , ... }: let domain = "woodpecker.example.org"; in { # This automatically sets up certificates via let's encrypt security.acme.defaults.email = "acme@example.com"; security.acme.acceptTerms = true; security.acme.certs."${domain}" = { }; # Setting up a nginx proxy that handles tls for us networking.firewall.allowedTCPPorts = [ 80 443 ]; services.nginx = { enable = true; recommendedTlsSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; virtualHosts."${domain}" = { enableACME = true; forceSSL = true; locations."/" = { proxyPass = "http://localhost:3007"; }; }; }; services.woodpecker-server = { enable = true; environment = { WOODPECKER_HOST = "https://${domain}"; WOODPECKER_SERVER_ADDR = ":3007"; WOODPECKER_OPEN = "true"; }; # You can pass a file with env vars to the system it could look like: # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX environmentFile = "/path/to/my/secrets/file"; }; # This sets up a woodpecker agent services.woodpecker-agents.agents."docker" = { enable = true; # We need this to talk to the podman socket extraGroups = [ "podman" ]; environment = { WOODPECKER_SERVER = "localhost:9000"; WOODPECKER_MAX_WORKFLOWS = "4"; DOCKER_HOST = "unix:///run/podman/podman.sock"; WOODPECKER_BACKEND = "docker"; }; # Same as with woodpecker-server environmentFile = [ "/var/lib/secrets/woodpecker.env" ]; }; # Here we setup podman and enable dns virtualisation.podman = { enable = true; defaultNetwork.settings = { dns_enabled = true; }; }; # This is needed for podman to be able to talk over dns networking.firewall.interfaces."podman0" = { allowedUDPPorts = [ 53 ]; allowedTCPPorts = [ 53 ]; }; } ``` All configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker) ## Tips and tricks There are some resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](../../92-awesome.md) page, like using the runners nix-store in the pipeline. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/05-deployment-methods/_category_.yaml ================================================ label: 'Deployment methods' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/10-database.md ================================================ # Databases The default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or Postgres database. ## Configure SQLite By default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database. ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] + volumes: + - woodpecker-server-data:/var/lib/woodpecker/ ``` ## Configure MySQL/MariaDB The below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples. The minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information. ```ini WOODPECKER_DATABASE_DRIVER=mysql WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true ``` ## Configure Postgres The below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples. Please use Postgres versions equal or higher than **11**. ```ini WOODPECKER_DATABASE_DRIVER=postgres WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable ``` ## Database Creation Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`. ## Database Migration Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes. ## Database Backups Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice. ## Database Archiving Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/10-server-config.md ================================================ --- toc_max_heading_level: 2 --- # Server configuration ## User registration Woodpecker does not have its own user registry; users are provided from your [forge](./11-forges/11-overview.md) (using OAuth2). Registration is closed by default (`WOODPECKER_OPEN=false`). If registration is open (`WOODPECKER_OPEN=true`) then every user with an account at the configured forge can login to Woodpecker. To open registration: ```ini WOODPECKER_OPEN=true ``` You can **also restrict** registration, by keep registration closed and: - **adding** new **users manually** via the CLI: `woodpecker-cli user add` - allowing specific **admin users** via the `WOODPECKER_ADMIN` setting - by open registration and **filter by organization** membership through the `WOODPECKER_ORGS` setting ### Close registration, but allow specific admin users ```ini WOODPECKER_OPEN=false WOODPECKER_ADMIN=johnsmith,janedoe ``` ### Only allow registration of users, who are members of approved organizations ```ini WOODPECKER_OPEN=true WOODPECKER_ORGS=dolores,dogpatch ``` ## Administrators Administrators should also be enumerated in your configuration. ```ini WOODPECKER_ADMIN=johnsmith,janedoe ``` ## Filtering repositories Woodpecker operates with the user's OAuth permission. Due to the coarse permission handling of GitHub, you may end up syncing more repos into Woodpecker than preferred. Use the `WOODPECKER_REPO_OWNERS` variable to filter which GitHub user's repos should be synced only. You typically want to put here your company's GitHub name. ```ini WOODPECKER_REPO_OWNERS=mycompany,mycompanyossgithubuser ``` ## Global registry setting If you want to make available a specific private registry to all pipelines, use the `WOODPECKER_DOCKER_CONFIG` server configuration. Point it to your server's docker config. ```ini WOODPECKER_DOCKER_CONFIG=/root/.docker/config.json ``` ## Handling sensitive data in docker-compose and docker-swarm To handle sensitive data in docker-compose or docker-swarm configurations there are several options: For docker-compose you can use a `.env` file next to your compose configuration to store the secrets outside of the compose file. While this separates configuration from secrets it is still not very secure. Alternatively use docker-secrets. As it may be difficult to use docker secrets for environment variables Woodpecker allows to read sensible data from files by providing a `*_FILE` option of all sensible configuration variables. Woodpecker will try to read the value directly from this file. Keep in mind that when the original environment variable gets specified at the same time it will override the value read from the file. ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret + secrets: + - woodpecker-agent-secret + + secrets: + woodpecker-agent-secret: + external: true ``` Store a value to a docker secret like this: ```bash echo "my_agent_secret_key" | docker secret create woodpecker-agent-secret - ``` or generate a random one like this: ```bash openssl rand -hex 32 | docker secret create woodpecker-agent-secret - ``` ## Custom JavaScript and CSS Woodpecker supports custom JS and CSS files. These files must be present in the server's filesystem. They can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment. The configuration variables are independent of each other, which means it can be just one file present, or both. ```ini WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js ``` The examples below show how to place a banner message in the top navigation bar of Woodpecker. ### `woodpecker.css` ```css .banner-message { position: absolute; width: 280px; height: 40px; margin-left: 240px; margin-top: 5px; padding-top: 5px; font-weight: bold; background: red no-repeat; text-align: center; } ``` ### `woodpecker.js` ```javascript // place/copy a minified version of jQuery or ZeptoJS here ... !(function () { 'use strict'; function e() {} /*...*/ })(); $().ready(function () { $('.app nav img').first().htmlAfter(""); }); ``` ## All server configuration options The following list describes all available server configuration options. ### `WOODPECKER_LOG_LEVEL` > Default: empty Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. ### `WOODPECKER_LOG_FILE` > Default: `stderr` Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. ### `WOODPECKER_LOG_XORM` > Default: `false` Enable XORM logs. ### `WOODPECKER_LOG_XORM_SQL` > Default: `false` Enable XORM SQL command logs. ### `WOODPECKER_DEBUG_PRETTY` > Default: `false` Enable pretty-printed debug output. ### `WOODPECKER_DEBUG_NOCOLOR` > Default: `true` Disable colored debug output. ### `WOODPECKER_HOST` > Default: empty Server fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix. Examples: - `WOODPECKER_HOST=http://woodpecker.example.org` - `WOODPECKER_HOST=http://example.org/woodpecker` - `WOODPECKER_HOST=http://example.org:1234/woodpecker` ### `WOODPECKER_WEBHOOK_HOST` > Default: value from `WOODPECKER_HOST` config env Server fully qualified URL of the Webhook-facing hostname and path prefix. Example: `WOODPECKER_WEBHOOK_HOST=http://woodpecker-server.cicd.svc.cluster.local:8000` ### `WOODPECKER_SERVER_ADDR` > Default: `:8000` Configures the HTTP listener port. ### `WOODPECKER_SERVER_ADDR_TLS` > Default: `:443` Configures the HTTPS listener port when SSL is enabled. ### `WOODPECKER_SERVER_CERT` > Default: empty Path to an SSL certificate used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_CERT=/path/to/cert.pem` ### `WOODPECKER_SERVER_KEY` > Default: empty Path to an SSL certificate key used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_KEY=/path/to/key.pem` ### `WOODPECKER_CUSTOM_CSS_FILE` > Default: empty File path for the server to serve a custom .CSS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css` ### `WOODPECKER_CUSTOM_JS_FILE` > Default: empty File path for the server to serve a custom .JS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js` ### `WOODPECKER_LETS_ENCRYPT` > Default: `false` Automatically generates an SSL certificate using Let's Encrypt, and configures the server to accept HTTPS requests. ### `WOODPECKER_GRPC_ADDR` > Default: `:9000` Configures the gRPC listener port. ### `WOODPECKER_GRPC_SECRET` > Default: `secret` Configures the gRPC JWT secret. ### `WOODPECKER_GRPC_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_GRPC_SECRET` from the specified filepath. ### `WOODPECKER_METRICS_SERVER_ADDR` > Default: empty Configures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely. Example: `:9001` ### `WOODPECKER_ADMIN` > Default: empty Comma-separated list of admin accounts. Example: `WOODPECKER_ADMIN=user1,user2` ### `WOODPECKER_ORGS` > Default: empty Comma-separated list of approved organizations. Example: `org1,org2` ### `WOODPECKER_REPO_OWNERS` > Default: empty Comma-separated list of syncable repo owners. ??? Example: `user1,user2` ### `WOODPECKER_OPEN` > Default: `false` Enable to allow user registration. ### `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS` > Default: `false` Always use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies. ### `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS` > Default: `pull_request, push` List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created. ### `WOODPECKER_DEFAULT_CLONE_IMAGE` > Default is defined in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) The default docker image to be used when cloning the repo ### `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT` > 60 (minutes) The default time for a repo in minutes before a pipeline gets killed ### `WOODPECKER_MAX_PIPELINE_TIMEOUT` > 120 (minutes) The maximum time in minutes you can set in the repo settings before a pipeline gets killed ### `WOODPECKER_SESSION_EXPIRES` > Default: `72h` Configures the session expiration time. Context: when someone does log into Woodpecker, a temporary session token is created. As long as the session is valid (until it expires or log-out), a user can log into Woodpecker, without re-authentication. ### `WOODPECKER_ESCALATE` > Defaults are defined in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) Docker images to run in privileged mode. Only change if you are sure what you do! You should specify the tag of your images too, as this enforces exact matches. ### `WOODPECKER_DOCKER_CONFIG` > Default: empty Configures a specific private registry config for all pipelines. Example: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json` ### `WOODPECKER_AGENT_SECRET` > Default: empty A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. ### `WOODPECKER_AGENT_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath ### `WOODPECKER_KEEPALIVE_MIN_TIME` > Default: empty Server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping. Example: `WOODPECKER_KEEPALIVE_MIN_TIME=10s` ### `WOODPECKER_DATABASE_DRIVER` > Default: `sqlite3` The database driver name. Possible values are `sqlite3`, `mysql` or `postgres`. ### `WOODPECKER_DATABASE_DATASOURCE` > Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container The database connection string. The default value is the path of the embedded SQLite database file. Example: ```bash # MySQL # https://github.com/go-sql-driver/mysql#dsn-data-source-name WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true # PostgreSQL # https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable ``` ### `WOODPECKER_DATABASE_DATASOURCE_FILE` > Default: empty Read the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath ### `WOODPECKER_PROMETHEUS_AUTH_TOKEN` > Default: empty Token to secure the Prometheus metrics endpoint. Must be set to enable the endpoint. ### `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE` > Default: empty Read the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath ### `WOODPECKER_STATUS_CONTEXT` > Default: `ci/woodpecker` Context prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository. ### `WOODPECKER_STATUS_CONTEXT_FORMAT` > Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}` Template for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language. Supported variables: - `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`) - `event`: the event which started the pipeline - `workflow`: the workflow's name - `owner`: the repo's owner - `repo`: the repo's name --- ### `WOODPECKER_LIMIT_MEM_SWAP` > Default: `0` The maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`. ### `WOODPECKER_LIMIT_MEM` > Default: `0` The maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`. ### `WOODPECKER_LIMIT_SHM_SIZE` > Default: `0` The maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`. ### `WOODPECKER_LIMIT_CPU_QUOTA` > Default: `0` The number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`. ### `WOODPECKER_LIMIT_CPU_SHARES` > Default: `0` The relative weight vs. other containers. ### `WOODPECKER_LIMIT_CPU_SET` > Default: empty Comma-separated list to limit the specific CPUs or cores a pipeline container can use. Example: `WOODPECKER_LIMIT_CPU_SET=1,2` ### `WOODPECKER_CONFIG_SERVICE_ENDPOINT` > Default: empty Specify a configuration service endpoint, see [Configuration Extension](./40-advanced/100-external-configuration-api.md) ### `WOODPECKER_FORGE_TIMEOUT` > Default: 3s Specify timeout when fetching the Woodpecker configuration from forge. See for syntax reference. ### `WOODPECKER_FORGE_RETRY` > Default: 3 Specify how many retries of fetching the Woodpecker configuration from a forge are done before we fail. ### `WOODPECKER_ENABLE_SWAGGER` > Default: true Enable the Swagger UI for API documentation. ### `WOODPECKER_DISABLE_VERSION_CHECK` > Default: false Disable version check in admin web UI. ### `WOODPECKER_LOG_STORE` > Default: `database` Where to store logs. Possible values: `database` or `file`. ### `WOODPECKER_LOG_STORE_FILE_PATH` > Default empty Directory to store logs in if [`WOODPECKER_LOG_STORE`](#woodpecker_log_store) is `file`. --- ### `WOODPECKER_GITHUB_...` See [GitHub configuration](./11-forges/20-github.md#configuration) ### `WOODPECKER_GITEA_...` See [Gitea configuration](./11-forges/30-gitea.md#configuration) ### `WOODPECKER_BITBUCKET_...` See [Bitbucket configuration](./11-forges/50-bitbucket.md#configuration) ### `WOODPECKER_GITLAB_...` See [GitLab configuration](./11-forges/40-gitlab.md#configuration) ### `WOODPECKER_ADDON_FORGE` See [addon forges](./11-forges/100-addon.md). ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/100-addon.md ================================================ # Addon forges If the forge you're using does not comply with [Woodpecker's requirements](../../92-development/02-core-ideas.md#forges) or your setup is too specific to be added to Woodpecker's core, you can write your own forge using an addon forge. :::warning Addon forges are still experimental. Their implementation can change and break at any time. ::: :::danger You need to trust the author of the addon forge you use. It can access authentication codes and other possibly sensitive information. ::: ## Usage To use an addon forge, download the correct addon version. Then, you can add the following to your configuration: ```ini WOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file ``` In case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`. ### Bug reports If you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name. ## List of addon forges If you wrote or found an addon forge, please add it here so others can find it! _Be the first one to add your addon forge!_ ## Creating addon forges Addons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin). ### Writing your code This example will use the Go language. Directly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/woodpecker/v2`) and use the interfaces and types defined there. In the `main` function, just call `"go.woodpecker-ci.org/woodpecker/v2/server/forge/addon".Serve` with a `"go.woodpecker-ci.org/woodpecker/v2/server/forge".Forge` as argument. This will take care of connecting the addon forge to the server. ### Example structure ```go package main import ( "context" "net/http" "go.woodpecker-ci.org/woodpecker/v2/server/forge/addon" forgeTypes "go.woodpecker-ci.org/woodpecker/v2/server/forge/types" "go.woodpecker-ci.org/woodpecker/v2/server/model" ) func main() { addon.Serve(config{}) } type config struct { } // `config` must implement `"go.woodpecker-ci.org/woodpecker/v2/server/forge".Forge`. You must directly use Woodpecker's packages - see imports above. ``` ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/11-overview.md ================================================ # Forges ## Supported features | Feature | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) | | ------------------------------------------------------------- | :--------------------: | :------------------: | :----------------------: | :--------------------: | :--------------------------: | :------------------------------------------------: | | Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | Event: Deploy | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | | [Multiple workflows](../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [when.path filter](../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/20-github.md ================================================ --- toc_max_heading_level: 2 --- # GitHub Woodpecker comes with built-in support for GitHub and GitHub Enterprise. To use Woodpecker with GitHub the following environment variables should be set for the server component: ```ini WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID WOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET ``` You will get these values from GitHub when you register your OAuth application. To do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App. :::warning Do not use a "GitHub App" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically) ::: ## App Settings - Name: An arbitrary name for your App - Homepage URL: The URL of your Woodpecker instance - Callback URL: `https:///authorize` - (optional) Upload the Woodpecker Logo: ## Client Secret Creation After your App has been created, you can generate a client secret. Use this one for the `WOODPECKER_GITHUB_SECRET` environment variable. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. ### `WOODPECKER_GITHUB` > Default: `false` Enables the GitHub driver. ### `WOODPECKER_GITHUB_URL` > Default: `https://github.com` Configures the GitHub server address. ### `WOODPECKER_GITHUB_CLIENT` > Default: empty Configures the GitHub OAuth client id to authorize access. ### `WOODPECKER_GITHUB_CLIENT_FILE` > Default: empty Read the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath. ### `WOODPECKER_GITHUB_SECRET` > Default: empty Configures the GitHub OAuth client secret. This is used to authorize access. ### `WOODPECKER_GITHUB_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath. ### `WOODPECKER_GITHUB_MERGE_REF` > Default: `true` ### `WOODPECKER_GITHUB_SKIP_VERIFY` > Default: `false` Configure if SSL verification should be skipped. ### `WOODPECKER_GITHUB_PUBLIC_ONLY` > Default: `false` Configures the GitHub OAuth client to only obtain a token that can manage public repositories. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/30-gitea.md ================================================ --- toc_max_heading_level: 2 --- # Gitea Woodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITEA=true WOODPECKER_GITEA_URL=YOUR_GITEA_URL WOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT WOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET ``` ## Gitea on the same host with containers If you have Gitea also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `gitea`, configure it like this: ```diff title="docker-compose.yaml" version: '3' services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea ``` ## Registration Register your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea./user/settings/`. It is very import the authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. If you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook). ![gitea oauth setup](gitea_oauth.gif) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. ### `WOODPECKER_GITEA` > Default: `false` Enables the Gitea driver. ### `WOODPECKER_GITEA_URL` > Default: `https://try.gitea.io` Configures the Gitea server address. ### `WOODPECKER_GITEA_CLIENT` > Default: empty Configures the Gitea OAuth client id. This is used to authorize access. ### `WOODPECKER_GITEA_CLIENT_FILE` > Default: empty Read the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath ### `WOODPECKER_GITEA_SECRET` > Default: empty Configures the Gitea OAuth client secret. This is used to authorize access. ### `WOODPECKER_GITEA_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_GITEA_SECRET` from the specified filepath ### `WOODPECKER_GITEA_SKIP_VERIFY` > Default: `false` Configure if SSL verification should be skipped. ## Advanced options ### `WOODPECKER_DEV_GITEA_OAUTH_URL` > Default: value of `WOODPECKER_GITEA_URL` Configures the user-facing Gitea server address. Should be used if `WOODPECKER_GITEA_URL` points to an internal URL used for API requests. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/35-forgejo.md ================================================ --- toc_max_heading_level: 2 --- # Forgejo :::warning Forgejo support is experimental. ::: Woodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_FORGEJO=true WOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL WOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT WOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET ``` ## Forgejo on the same host with containers If you have Forgejo also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `forgejo`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo ``` ## Registration Register your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo./user/settings/`. It is very import the authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. If you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook). ![forgejo oauth setup](gitea_oauth.gif) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. ### `WOODPECKER_FORGEJO` > Default: `false` Enables the Forgejo driver. ### `WOODPECKER_FORGEJO_URL` > Default: `https://next.forgejo.org` Configures the Forgejo server address. ### `WOODPECKER_FORGEJO_CLIENT` > Default: empty Configures the Forgejo OAuth client id. This is used to authorize access. ### `WOODPECKER_FORGEJO_CLIENT_FILE` > Default: empty Read the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath ### `WOODPECKER_FORGEJO_SECRET` > Default: empty Configures the Forgejo OAuth client secret. This is used to authorize access. ### `WOODPECKER_FORGEJO_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath ### `WOODPECKER_FORGEJO_SKIP_VERIFY` > Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/40-gitlab.md ================================================ --- toc_max_heading_level: 2 --- # GitLab Woodpecker comes with built-in support for the GitLab version 8.2 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITLAB=true WOODPECKER_GITLAB_URL=http://gitlab.mycompany.com WOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82 WOODPECKER_GITLAB_SECRET=30f5064039e6b359e075 ``` ## Registration You must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application. Please use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application. If you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. ### `WOODPECKER_GITLAB` > Default: `false` Enables the GitLab driver. ### `WOODPECKER_GITLAB_URL` > Default: `https://gitlab.com` Configures the GitLab server address. ### `WOODPECKER_GITLAB_CLIENT` > Default: empty Configures the GitLab OAuth client id. This is used to authorize access. ### `WOODPECKER_GITLAB_CLIENT_FILE` > Default: empty Read the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath ### `WOODPECKER_GITLAB_SECRET` > Default: empty Configures the GitLab OAuth client secret. This is used to authorize access. ### `WOODPECKER_GITLAB_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath ### `WOODPECKER_GITLAB_SKIP_VERIFY` > Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/50-bitbucket.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Woodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_BITBUCKET=true WOODPECKER_BITBUCKET_CLIENT=... # called "Key" in Bitbucket WOODPECKER_BITBUCKET_SECRET=... ``` ## Registration You must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`). Please set a name and set the `Callback URL` like this: ```uri https:///authorize ``` ![bitbucket oauth setup](bitbucket_oauth.png) Please also be sure to check the following permissions: - Account: Email, Read - Workspace membership: Read - Projects: Read - Repositories: Read - Pull requests: Read - Webhooks: Read and Write ![bitbucket permissions](bitbucket_permissions.png) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. ### `WOODPECKER_BITBUCKET` > Default: `false` Enables the Bitbucket driver. ### `WOODPECKER_BITBUCKET_CLIENT` > Default: empty Configures the Bitbucket OAuth client key. This is used to authorize access. ### `WOODPECKER_BITBUCKET_CLIENT_FILE` > Default: empty Read the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath ### `WOODPECKER_BITBUCKET_SECRET` > Default: empty Configures the Bitbucket OAuth client secret. This is used to authorize access. ### `WOODPECKER_BITBUCKET_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath ## Missing Features Path filters for pull requests are not supported. We are interested in patches to include this functionality. If you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de). ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/60-bitbucket_datacenter.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Datacenter / Server :::warning Woodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash. ::: To enable Bitbucket Server you should configure the Woodpecker container using the following environment variables: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_BITBUCKET_DC=true + - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo + - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar + - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx + - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy + - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com woodpecker-agent: [...] ``` ## Service Account Woodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories. ## Registration Woodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose "Application Links" and then "Create link". Woodpecker should be listed as "External Application" and the direction should be set to "Incomming". Note the client id and client secret of the registration to be used in the configuration of Woodpecker. See also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html). ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. ### `WOODPECKER_BITBUCKET_DC` > Default: `false` Enables the Bitbucket Server driver. ### `WOODPECKER_BITBUCKET_DC_URL` > Default: empty Configures the Bitbucket Server address. ### `WOODPECKER_BITBUCKET_DC_CLIENT_ID` > Default: empty Configures your Bitbucket Server OAUth 2.0 client id. ### `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET` > Default: empty Configures your Bitbucket Server OAUth 2.0 client secret. ### `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` > Default: empty This username is used to authenticate and clone all private repositories. ### `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE` > Default: empty Read the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath ### `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` > Default: empty The password is used to authenticate and clone all private repositories. ### `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE` > Default: empty Read the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath ### `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY` > Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/11-forges/_category_.yaml ================================================ label: 'Forges' collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/15-agent-config.md ================================================ --- toc_max_heading_level: 2 --- # Agent configuration Agents are configured by the command line or environment variables. At the minimum you need the following information: ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" ``` The following are automatically set and can be overridden: - `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname - `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1 ## Workflows per agent By default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent. ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" WOODPECKER_MAX_WORKFLOWS=4 ``` ## Agent registration When the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before. There are two types of tokens to connect an agent to the server: ### Using system token A _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents. In that case registration process would be as following: 1. The first time the agent communicates with the server, it is using the system token 1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent 1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`) 1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server ### Using agent token An _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`. To get an _agent token_ you have to register the agent manually in the server using the UI: 1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent` ![Agent creation](./new-agent-registration.png) ![Agent created](./new-agent-created.png) 1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET` 1. The agent will connect to the server using the provided token and will update its status in the UI: ![Agent connected](./new-agent-connected.png) ## All agent configuration options Here is the full list of configuration options and their default variables. ### `WOODPECKER_SERVER` > Default: `localhost:9000` Configures gRPC address of the server. ### `WOODPECKER_USERNAME` > Default: `x-oauth-basic` The gRPC username. ### `WOODPECKER_AGENT_SECRET` > Default: empty A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. ### `WOODPECKER_AGENT_SECRET_FILE` > Default: empty Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf` ### `WOODPECKER_LOG_LEVEL` > Default: empty Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. ### `WOODPECKER_DEBUG_PRETTY` > Default: `false` Enable pretty-printed debug output. ### `WOODPECKER_DEBUG_NOCOLOR` > Default: `true` Disable colored debug output. ### `WOODPECKER_HOSTNAME` > Default: empty Configures the agent hostname. ### `WOODPECKER_AGENT_CONFIG_FILE` > Default: `/etc/woodpecker/agent.conf` Configures the path of the agent config file. ### `WOODPECKER_MAX_WORKFLOWS` > Default: `1` Configures the number of parallel workflows. ### `WOODPECKER_FILTER_LABELS` > Default: empty Configures labels to filter pipeline pick up. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. By default, agents provide three additional labels `platform=os/arch`, `hostname=my-agent` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../20-usage/20-workflow-syntax.md#labels). ### `WOODPECKER_HEALTHCHECK` > Default: `true` Enable healthcheck endpoint. ### `WOODPECKER_HEALTHCHECK_ADDR` > Default: `:3000` Configures healthcheck endpoint address. ### `WOODPECKER_KEEPALIVE_TIME` > Default: empty After a duration of this time of no activity, the agent pings the server to check if the transport is still alive. ### `WOODPECKER_KEEPALIVE_TIMEOUT` > Default: `20s` After pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity. ### `WOODPECKER_GRPC_SECURE` > Default: `false` Configures if the connection to `WOODPECKER_SERVER` should be made using a secure transport. ### `WOODPECKER_GRPC_VERIFY` > Default: `true` Configures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`. ### `WOODPECKER_BACKEND` > Default: `auto-detect` Configures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`. ### `WOODPECKER_BACKEND_DOCKER_*` See [Docker backend configuration](./22-backends/10-docker.md#configuration) ### `WOODPECKER_BACKEND_K8S_*` See [Kubernetes backend configuration](./22-backends/40-kubernetes.md#configuration) ### `WOODPECKER_BACKEND_LOCAL_*` See [Local backend configuration](./22-backends/20-local.md#options) ## Advanced Settings :::warning Only change these If you know what you do. ::: ### `WOODPECKER_CONNECT_RETRY_COUNT` > Default: `5` Configures number of times agent retries to connect to the server. ### `WOODPECKER_CONNECT_RETRY_DELAY` > Default: `2s` Configures delay between agent connection retries to the server. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/22-backends/10-docker.md ================================================ --- toc_max_heading_level: 2 --- # Docker backend This is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent. ## Docker credentials Woodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server-config.md#woodpecker_docker_config). To add your credential helper to the Woodpecker server container you could use the following code to build a custom image: ```dockerfile FROM woodpeckerci/woodpecker-server:latest-alpine RUN apk add -U --no-cache docker-credential-ecr-login ``` ## Podman support While the agent was developed with Docker/Moby, Podman can also be used by setting the environment variable `DOCKER_HOST` to point to the Podman socket. In order to work without workarounds, Podman 4.0 (or above) is required. ## Image cleanup The agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner. :::danger The following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation. ::: ### Remove all unused images ```bash docker image rm $(docker images --filter "dangling=true" -q --no-trunc) ``` ### Remove Woodpecker volumes ```bash docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true -q) ``` ## Configuration ### `WOODPECKER_BACKEND_DOCKER_NETWORK` > Default: empty Set to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other! ### `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6` > Default: `false` Enable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6. ### `WOODPECKER_BACKEND_DOCKER_VOLUMES` > Default: empty List of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA certificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/22-backends/20-local.md ================================================ --- toc_max_heading_level: 3 --- # Local backend :::danger The local backend executes pipelines on the local system without any isolation. ::: :::note Currently we do not support [services](../../20-usage/60-services.md) for this backend. [Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095). ::: Since the commands run directly in the same context as the agent (same user, same filesystem), a malicious pipeline could be used to access the agent configuration especially the `WOODPECKER_AGENT_SECRET` variable. It is recommended to use this backend only for private setup where the code and pipeline can be trusted. It should not be used in a public instance where anyone can submit code or add new repositories. The agent should not run as a privileged user (root). The local backend will use a random directory in `$TMPDIR` to store the cloned code and execute commands. In order to use this backend, you need to download (or build) the [agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine. ## Usage To enable the local backend, set the following: ```ini WOODPECKER_BACKEND=local ``` ### Shell The `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is used to run the commands. ```yaml title=".woodpecker.yaml" steps: - name: build image: bash commands: [...] ``` ### Plugins ```yaml steps: - name: build image: /usr/bin/tree ``` If no commands are provided, plugins are treated in the usual manner. In the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path. ### Options #### `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` > Default: default temp directory Directory to create folders for workflows. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/22-backends/40-kubernetes.md ================================================ --- toc_max_heading_level: 2 --- # Kubernetes backend The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps. ## Images from private registries In order to pull private container images defined in your pipeline YAML you must provide [registry credentials in Kubernetes Secret](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/). As the Secret is Agent-wide, it has to be placed in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE`. Besides, you need to provide the Secret name to Agent via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`. ## Job specific configuration ### Resources The Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory. We recommend to add a `resources` definition to all steps to ensure efficient scheduling. Here is an example definition with an arbitrary `resources` definition below the `backend_options` section: ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: resources: requests: memory: 200Mi cpu: 100m limits: memory: 400Mi cpu: 1000m ``` You can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis. ### Runtime class `runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes. ### Service account `serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts. ### Node selector `nodeSelector` specifies the labels which are used to select the node on which the job will be executed. Labels defined here will be appended to a list which already contains `"kubernetes.io/arch"`. By default `"kubernetes.io/arch"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`. Without a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures. To overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`. A practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture. In this case, one must define an arbitrary key in the matrix section of the respective matrix element: ```yaml matrix: include: - NAME: runner1 ARCH: arm64 ``` And then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var: ```yaml [...] backend_options: kubernetes: nodeSelector: kubernetes.io/arch: "${ARCH}" ``` You can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#woodpecker_backend_k8s_pod_node_selector) if you want to set the node selector per Agent or [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis. ### Tolerations When you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations. Example pipeline configuration: ```yaml steps: - name: build image: golang commands: - go get - go build - go test backend_options: kubernetes: serviceAccountName: 'my-service-account' resources: requests: memory: 128Mi cpu: 1000m limits: memory: 256Mi nodeSelector: beta.kubernetes.io/instance-type: p3.8xlarge tolerations: - key: 'key1' operator: 'Equal' value: 'value1' effect: 'NoSchedule' tolerationSeconds: 3600 ``` ### Volumes To mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option. Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a step: ```yaml steps: - name: "Restore Cache" image: meltwater/drone-cache volumes: - woodpecker-cache:/woodpecker/src/cache settings: mount: - "woodpecker-cache" [...] ``` ### Security context Use the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step: ```yaml steps: - name: test image: alpine commands: - echo Hello world backend_options: kubernetes: securityContext: runAsUser: 999 runAsGroup: 999 privileged: true [...] ``` Note that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object. By default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the configuration shown above will result in something like the following Pod spec: ```yaml kind: Pod spec: securityContext: runAsUser: 999 runAsGroup: 999 containers: - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0 image: alpine securityContext: privileged: true [...] ``` You can also restrict a container's syscalls with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile ```yaml backend_options: kubernetes: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/audit.json ``` or restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile ```yaml backend_options: kubernetes: securityContext: apparmorProfile: type: Localhost localhostProfile: k8s-apparmor-example-deny-write ``` :::note AppArmor syntax follows [KEP-24](https://github.com/kubernetes/enhancements/blob/fddcbb9cbf3df39ded03bad71228265ac6e5215f/keps/sig-node/24-apparmor/README.md). ::: ### Annotations and labels You can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration: ```yaml backend_options: kubernetes: annotations: workflow-group: alpha io.kubernetes.cri-o.Devices: /dev/fuse labels: environment: ci app.kubernetes.io/name: builder ``` In order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent: [WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#woodpecker_backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#woodpecker_backend_k8s_pod_labels_allow_from_step). ## Tips and tricks ### CRI-O CRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration: ```yaml workspace: base: '/woodpecker' path: '/' ``` See [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details. ### `KUBERNETES_SERVICE_HOST` environment variable Like the below env vars used for configuration, this can be set in the environment fonfiguration of the agent. It configures the address of the Kubernetes API server to connect to. If running the agent within Kubernetes, this will already be set and you don't have to add it manually. ## Configuration These env vars can be set in the `env:` sections of the agent. ### `WOODPECKER_BACKEND_K8S_NAMESPACE` > Default: `woodpecker` The namespace to create worker Pods in. ### `WOODPECKER_BACKEND_K8S_VOLUME_SIZE` > Default: `10G` The volume size of the pipeline volume. ### `WOODPECKER_BACKEND_K8S_STORAGE_CLASS` > Default: empty The storage class to use for the pipeline volume. ### `WOODPECKER_BACKEND_K8S_STORAGE_RWX` > Default: `true` Determines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead. ### `WOODPECKER_BACKEND_K8S_POD_LABELS` > Default: empty Additional labels to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-label":"test-value"}`. ### `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP` > Default: `false` Determines if additional Pod labels can be defined from a step's backend options. ### `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS` > Default: empty Additional annotations to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-annotation":"test-value"}`. ### `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP` > Default: `false` Determines if Pod annotations can be defined from a step's backend options. ### `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR` > Default: empty Additional node selector to apply to worker pods. Must be a YAML object, e.g. `{"topology.kubernetes.io/region":"eu-central-1"}`. ### `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT` > Default: `false` Determines if containers must be required to run as non-root users. ### `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` > Default: empty Secret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/). ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/22-backends/50-custom-backends.md ================================================ # Custom backends If none of our backends fits your usecases, you can write your own. Therefore, implement the interface `"go.woodpecker-ci.org/woodpecker/woodpecker/v2/pipeline/backend/types".Backend` and build a custom agent using your backend with this `main.go`: ```go package main import ( "go.woodpecker-ci.org/woodpecker/v2/cmd/agent/core" backendTypes "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types" ) func main() { core.RunAgent([]backendTypes.Backend{ yourBackend, }) } ``` It is also possible to use multiple backends, you can select with [`WOODPECKER_BACKEND`](../15-agent-config.md#woodpecker_backend) between them. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/22-backends/_category_.yaml ================================================ label: 'Backends' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/10-proxy.md ================================================ # Proxy ## Apache This guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration: ```apacheconf ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` You must have these Apache modules installed: - `proxy` - `proxy_http` You must configure Apache to set `X-Forwarded-Proto` when using https. ```diff ProxyPreserveHost On +RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` ## Nginx This guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide). Example configuration: ```nginx server { listen 80; server_name woodpecker.example.com; location / { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` You must configure the proxy to set `X-Forwarded` proxy headers: ```diff server { listen 80; server_name woodpecker.example.com; location / { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` ## Caddy This guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration: ```caddy # expose WebUI and API woodpecker.example.com { reverse_proxy woodpecker-server:8000 } # expose gRPC woodpeckeragent.example.com { reverse_proxy h2c://woodpecker-server:9000 } ``` :::note Above configuration shows how to create reverse-proxies for web and agent communication. If your agent uses SSL do not forget to enable [`WOODPECKER_GRPC_SECURE`](../15-agent-config.md#woodpecker_grpc_secure). ::: ## Tunnelmole [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool. Start by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation). After the installation, run the following command to start tunnelmole: ```bash tmole 8000 ``` It will start a tunnel and will give a response like this: ```bash ➜ ~ tmole 8000 http://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 https://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 ``` Set `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server. ## Ngrok [Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command: ```bash ngrok http 8000 ``` Set `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server. ## Traefik To install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https. ```yaml version: '3.8' services: server: image: woodpeckerci/woodpecker-server:latest environment: - WOODPECKER_OPEN=true - WOODPECKER_ADMIN=your_admin_user # other settings ... networks: - dmz # externally defined network, so that traefik can connect to the server volumes: - woodpecker-server-data:/var/lib/woodpecker/ deploy: labels: - traefik.enable=true # web server - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000 - traefik.http.routers.woodpecker-secure.rule=Host(`cd.yourdomain.com`) - traefik.http.routers.woodpecker-secure.tls=true - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-secure.entrypoints=websecure - traefik.http.routers.woodpecker-secure.service=woodpecker-service - traefik.http.routers.woodpecker.rule=Host(`cd.yourdomain.com`) - traefik.http.routers.woodpecker.entrypoints=web - traefik.http.routers.woodpecker.service=woodpecker-service - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker # gRPC service - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000 - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.yourdomain.com`) - traefik.http.routers.woodpecker-grpc-secure.tls=true - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-grpc-secure.entrypoints=websecure - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.yourdomain.com`) - traefik.http.routers.woodpecker-grpc.entrypoints=web - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker volumes: woodpecker-server-data: driver: local networks: dmz: external: true ``` You should pass `WOODPECKER_GRPC_SECURE=true` and `WOODPECKER_GRPC_VERIFY=true` to your agent when using this configuration. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/100-external-configuration-api.md ================================================ # External Configuration API To provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service. Before the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration. Every request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`. A simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service) :::warning You need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks. ::: ## Config ```ini title="Server" WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig ``` ### Example request made by Woodpecker ```json { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-testpipe", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["somefilename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "configs": [ { "name": ".woodpecker.yaml", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from Repo (.woodpecker.yaml)\"\n" } ] } ``` ### Example response structure ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/20-ssl.md ================================================ # SSL Woodpecker supports two ways of enabling SSL communication. You can either use Let's Encrypt to get automated SSL support with renewal or provide your own SSL certificates. ## Let's Encrypt Woodpecker supports automated SSL configuration and updates using Let's Encrypt. You can enable Let's Encrypt by making the following modifications to your server configuration: ```ini WOODPECKER_LETS_ENCRYPT=true WOODPECKER_LETS_ENCRYPT_EMAIL=ssl-admin@example.tld ``` Note that Woodpecker uses the hostname from the `WOODPECKER_HOST` environment variable when requesting certificates. For example, if `WOODPECKER_HOST=https://example.com` is set the certificate is requested for `example.com`. To receive emails before certificates expire Let's Encrypt requires an email address. You can set it with `WOODPECKER_LETS_ENCRYPT_EMAIL=ssl-admin@example.tld`. The SSL certificates are stored in `$HOME/.local/share/certmagic` for binary versions of Woodpecker and in `/var/lib/woodpecker` for the Container versions of it. You can set a custom path by setting `XDG_DATA_HOME` if required. > Once enabled you can visit the Woodpecker UI with http and the HTTPS address. HTTP will be redirected to HTTPS. ### Certificate Cache Woodpecker writes the certificates to `/var/lib/woodpecker/certmagic/`. ### Certificate Updates Woodpecker uses the official Go acme library which will handle certificate upgrades. There should be no addition configuration or management required. ## SSL with own certificates Woodpecker supports SSL configuration by mounting certificates into your container. ```ini WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key ``` ### Certificate Chain The most common problem encountered is providing a certificate file without the intermediate chain. > LoadX509KeyPair reads and parses a public/private key pair from a pair of files. The files must contain PEM encoded data. The certificate file may contain intermediate certificates following the leaf certificate to form a certificate chain. ### Certificate Errors SSL support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library. If you receive certificate errors or warnings please examine your configuration more closely. ### Running in containers Update your configuration to expose the following ports: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] ports: + - 80:80 + - 443:443 - 9000:9000 ``` Update your configuration to mount your certificate and key: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] volumes: + - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt + - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key ``` Update your configuration to provide the paths of your certificate and key: ```diff title="docker-compose.yaml" version: '3' services: woodpecker-server: [...] environment: + - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt + - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key ``` ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/30-autoscaler.md ================================================ # Autoscaler If your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler). Please note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap). ## Setup ### docker-compose If you are using docker-compose you can add the following to your `docker-compose.yaml` file: ```yaml version: '3' services: woodpecker-server: image: woodpeckerci/woodpecker-server:next [...] woodpecker-autoscaler: image: woodpeckerci/autoscaler:next restart: always depends_on: - woodpecker-server environment: - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user - WOODPECKER_MIN_AGENTS=0 - WOODPECKER_MAX_AGENTS=3 - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time - WOODEPCKER_GRPC_ADDR=https://grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents - WOODEPCKER_GRPC_SECURE=true - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud ``` ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/40-advanced.md ================================================ # Adavanced options Why should we be happy with a default setup? We should not! Woodpecker offers a lot of advanced options to configure it to your needs. ## Behind a proxy See the [proxy guide](./10-proxy.md) if you want to see a setup behind Apache, Nginx, Caddy or ngrok. In the case you need to use Woodpecker with a URL path prefix (like: ), add the root path to [`WOODPECKER_HOST`](../10-server-config.md#woodpecker_host). ## SSL Woodpecker supports SSL configuration by using Let's encrypt or by using own certificates. See the [SSL guide](./20-ssl.md). ## Metrics A [Prometheus endpoint](./90-prometheus.md) is exposed by Woodpecker to collect metrics. ## Autoscaling The [autoscaler](./30-autoscaler.md) can be used to deploy new agents to a cloud provider based on the current workload your server is experiencing. ## Configuration service Sometime the normal yaml configuration compiler isn't enough. You can use the [configuration service](./100-external-configuration-api.md) to process your configuration files by your own. ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/90-prometheus.md ================================================ # Prometheus Woodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above. ```yaml global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` ## Authorization An administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` As an alternative, the token can also be read from a file: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token_file: /etc/secrets/woodpecker-monitoring-token static_configs: - targets: ['woodpecker.domain.com'] ``` ## Metric Reference List of Prometheus metrics specific to Woodpecker: ```yaml # HELP woodpecker_pipeline_count Pipeline count. # TYPE woodpecker_pipeline_count counter woodpecker_pipeline_count{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 woodpecker_pipeline_count{branch="mkdocs",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 # HELP woodpecker_pipeline_time Build time. # TYPE woodpecker_pipeline_time gauge woodpecker_pipeline_time{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 116 woodpecker_pipeline_time{branch="mkdocs",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 155 # HELP woodpecker_pipeline_total_count Total number of builds. # TYPE woodpecker_pipeline_total_count gauge woodpecker_pipeline_total_count 1025 # HELP woodpecker_pending_steps Total number of pending pipeline steps. # TYPE woodpecker_pending_steps gauge woodpecker_pending_steps 0 # HELP woodpecker_repo_count Total number of repos. # TYPE woodpecker_repo_count gauge woodpecker_repo_count 9 # HELP woodpecker_running_steps Total number of running pipeline steps. # TYPE woodpecker_running_steps gauge woodpecker_running_steps 0 # HELP woodpecker_user_count Total number of users. # TYPE woodpecker_user_count gauge woodpecker_user_count 1 # HELP woodpecker_waiting_steps Total number of pipeline waiting on deps. # TYPE woodpecker_waiting_steps gauge woodpecker_waiting_steps 0 # HELP woodpecker_worker_count Total number of workers. # TYPE woodpecker_worker_count gauge woodpecker_worker_count 4 ``` ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/40-advanced/_category_.yaml ================================================ label: 'Advanced' collapsible: true collapsed: true link: type: 'doc' id: 'advanced' ================================================ FILE: docs/versioned_docs/version-2.8/30-administration/_category_.yaml ================================================ label: 'Administration' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-2.8/40-cli.md ================================================ # CLI # NAME woodpecker-cli - command line utility # SYNOPSIS woodpecker-cli ``` [--config|-c]=[value] [--disable-update-check] [--log-file]=[value] [--log-level]=[value] [--nocolor] [--pretty] [--server|-s]=[value] [--token|-t]=[value] ``` # DESCRIPTION Woodpecker command line utility **Usage**: ``` woodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...] ``` # GLOBAL OPTIONS **--config, -c**="": path to config file **--disable-update-check**: disable update check **--log-file**="": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr) **--log-level**="": set logging level (default: info) **--nocolor**: disable colored debug output, only has effect if pretty output is set too **--pretty**: enable pretty-printed debug output **--server, -s**="": server address **--token, -t**="": server auth token # COMMANDS ## admin administer server settings ### registry manage global registries #### add adds a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username #### info display registry info **--hostname**="": registry hostname (default: docker.io) #### ls list registries ## org manage organizations ### registry manage organization registries #### add adds a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username #### info display registry info **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list registries **--organization, --org**="": organization id or full name (e.g. 123 or octocat) ## repo manage repositories ### ls list all repos **--format**="": format output (default: {{ .FullName }} (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }})) **--org**="": filter by organization ### info show repository details **--format**="": format output (default: Owner: {{ .Owner }} Repo: {{ .Name }} URL: {{ .ForgeURL }} Config path: {{ .Config }} Visibility: {{ .Visibility }} Private: {{ .IsSCMPrivate }} Trusted: {{ .IsTrusted }} Gated: {{ .IsGated }} Require approval for: {{ .RequireApproval }} Clone url: {{ .Clone }} Allow pull-requests: {{ .AllowPullRequests }} ) ### add add a repository ### update update a repository **--config**="": repository configuration path (e.g. .woodpecker.yml) **--gated**: [deprecated] repository is gated **--pipeline-counter**="": repository starting pipeline number (default: 0) **--require-approval**="": repository requires approval for **--timeout**="": repository timeout (default: 0s) **--trusted**: repository is trusted **--unsafe**: validate updating the pipeline-counter is unsafe **--visibility**="": repository visibility ### rm remove a repository ### repair repair repository webhooks ### chown assume ownership of a repository ### sync synchronize the repository list **--format**="": format output (default: {{ .FullName }} (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }})) ### registry manage registries #### add adds a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username #### info display registry info **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list registries **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ## pipeline manage pipelines ### ls show pipeline history **--branch**="": branch filter **--event**="": event filter **--limit**="": limit the list size (default: 25) **--output**="": output format (default: table) **--output-no-headers**: don't print headers **--status**="": status filter ### last show latest pipeline details **--branch**="": branch name (default: main) **--output**="": output format (default: table) **--output-no-headers**: don't print headers ### logs show pipeline logs ### info show pipeline details **--output**="": output format (default: table) **--output-no-headers**: don't print headers ### stop stop a pipeline ### start start a pipeline **--param, -p**="": custom parameters to be injected into the step environment. Format: KEY=value (default: []) ### approve approve a pipeline ### decline decline a pipeline ### queue show pipeline queue **--format**="": format output (default: {{ .FullName }} #{{ .Number }}  Status: {{ .Status }} Event: {{ .Event }} Commit: {{ .Commit }} Branch: {{ .Branch }} Ref: {{ .Ref }} Author: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }} Message: {{ .Message }} ) ### ps show pipeline steps **--format**="": format output (default: {{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}): Step: {{ .step.Name }} Started: {{ .step.Started }} Stopped: {{ .step.Stopped }} Type: {{ .step.Type }} State: {{ .step.State }} ) ### create create new pipeline **--branch**="": branch to create pipeline from **--output**="": output format (default: table) **--output-no-headers**: don't print headers **--var**="": key=value (default: []) ## log manage logs ### purge purge a log ## deploy trigger a pipeline with the 'deployment' event **--branch**="": branch filter **--event**="": event filter (default: push) **--format**="": format output (default: Number: {{ .Number }} Status: {{ .Status }} Commit: {{ .Commit }} Branch: {{ .Branch }} Ref: {{ .Ref }} Message: {{ .Message }} Author: {{ .Author }} Target: {{ .Deploy }} ) **--param, -p**="": custom parameters to be injected into the step environment. Format: KEY=value (default: []) **--status**="": status filter (default: success) ## exec execute a local pipeline **--backend-docker-api-version**="": the version of the API to reach, leave empty for latest. **--backend-docker-cert**="": path to load the TLS certificates for connecting to docker server **--backend-docker-host**="": path to docker socket or url to the docker server **--backend-docker-ipv6**: backend docker enable IPV6 **--backend-docker-network**="": backend docker network **--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server **--backend-docker-volumes**="": backend docker volumes (comma separated) **--backend-engine**="": backend engine to run pipelines on (default: auto-detect) **--backend-http-proxy**="": if set, pass the environment variable down as "HTTP_PROXY" to steps **--backend-https-proxy**="": if set, pass the environment variable down as "HTTPS_PROXY" to steps **--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps **--backend-k8s-namespace**="": backend k8s namespace (default: woodpecker) **--backend-k8s-pod-annotations**="": backend k8s additional Agent-wide worker pod annotations **--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options **--backend-k8s-pod-image-pull-secret-names**="": backend k8s pull secret names for private registries (default: [regcred]) **--backend-k8s-pod-labels**="": backend k8s additional Agent-wide worker pod labels **--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options **--backend-k8s-pod-node-selector**="": backend k8s Agent-wide worker pod node selector **--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option **--backend-k8s-storage-class**="": backend k8s storage class **--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) **--backend-k8s-volume-size**="": backend k8s volume size (default 10G) (default: 10G) **--backend-local-temp-dir**="": set a different temp dir to clone workflows into (default: /tmp/nix-shell.OgDG7Z) **--backend-no-proxy**="": if set, pass the environment variable down as "NO_PROXY" to steps **--commit-author-avatar**="": **--commit-author-email**="": **--commit-author-name**="": **--commit-branch**="": **--commit-message**="": **--commit-ref**="": **--commit-refspec**="": **--commit-sha**="": **--env**="": (default: []) **--forge-type**="": **--forge-url**="": **--local**: run from local directory **--netrc-machine**="": **--netrc-password**="": **--netrc-username**="": **--network**="": external networks (default: []) **--pipeline-created**="": (default: 0) **--pipeline-deploy-task**="": **--pipeline-deploy-to**="": **--pipeline-event**="": (default: manual) **--pipeline-finished**="": (default: 0) **--pipeline-number**="": (default: 0) **--pipeline-parent**="": (default: 0) **--pipeline-started**="": (default: 0) **--pipeline-status**="": **--pipeline-url**="": **--prev-commit-author-avatar**="": **--prev-commit-author-email**="": **--prev-commit-author-name**="": **--prev-commit-branch**="": **--prev-commit-message**="": **--prev-commit-ref**="": **--prev-commit-refspec**="": **--prev-commit-sha**="": **--prev-pipeline-created**="": (default: 0) **--prev-pipeline-event**="": **--prev-pipeline-finished**="": (default: 0) **--prev-pipeline-number**="": (default: 0) **--prev-pipeline-started**="": (default: 0) **--prev-pipeline-status**="": **--prev-pipeline-url**="": **--privileged**="": privileged plugins (default: [plugins/docker plugins/gcr plugins/ecr woodpeckerci/plugin-docker-buildx codeberg.org/woodpecker-plugins/docker-buildx]) **--repo**="": full repo name **--repo-clone-ssh-url**="": **--repo-clone-url**="": **--repo-path**="": path to local repository **--repo-private**="": **--repo-remote-id**="": **--repo-trusted**: **--repo-url**="": **--step-name**="": (default: 0) **--system-name**="": (default: woodpecker) **--system-platform**="": **--system-url**="": (default: https://github.com/woodpecker-ci/woodpecker) **--timeout**="": pipeline timeout (default: 1h0m0s) **--volumes**="": pipeline volumes (default: []) **--workflow-name**="": (default: 0) **--workflow-number**="": (default: 0) **--workspace-base**="": (default: /woodpecker) **--workspace-path**="": (default: src) ## info show information about the current user ## registry manage registries ### add adds a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username ### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ### update update a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username ### info display registry info **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ### ls list registries **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ## secret manage secrets ### add adds a secret **--event**="": secret limited to these events (default: []) **--global**: global secret **--image**="": secret limited to these images (default: []) **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value ### rm remove a secret **--global**: global secret **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ### update update a secret **--event**="": secret limited to these events (default: []) **--global**: global secret **--image**="": secret limited to these images (default: []) **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value ### info display secret info **--global**: global secret **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ### ls list secrets **--global**: global secret **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ## user manage users ### ls list all users **--format**="": format output (default: {{ .Login }}) ### info show user details **--format**="": format output (default: User: {{ .Login }} Email: {{ .Email }}) ### add adds a user ### rm remove a user ## lint lint a pipeline configuration file ## log-level get the logging level of the server, or set it with [level] ## cron manage cron jobs ### add add a cron job **--branch**="": cron branch **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule ### rm remove a cron job **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ### update update a cron job **--branch**="": cron branch **--id**="": cron id **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule ### info display info about a cron job **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ### ls list cron jobs **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) ## setup setup the woodpecker-cli for the first time **--server**="": The URL of the woodpecker server **--token**="": The token to authenticate with the woodpecker server ## update update the woodpecker-cli to the latest version **--force**: force update even if the latest version is already installed ================================================ FILE: docs/versioned_docs/version-2.8/50-about.md ================================================ # About Woodpecker has been originally forked from Drone 0.8 as the Drone CI license was changed after the 0.8 release from Apache 2.0 to a proprietary license. Woodpecker is based on this latest freely available version. ## History Woodpecker was originally forked by [@laszlocph](https://github.com/laszlocph) in 2019. A few important time points: - [`2fbaa56`](https://github.com/woodpecker-ci/woodpecker/commit/2fbaa56eee0f4be7a3ca4be03dbd00c1bf5d1274) is the first commit of the fork, made on Apr 3, 2019. - The first release [v0.8.91](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.91) was published on Apr 6, 2019. - On Aug 27, 2019, the project was renamed to "Woodpecker" ([`630c383`](https://github.com/woodpecker-ci/woodpecker/commit/630c383181b10c4ec375e500c812c4b76b3c52b8)). - The first release under the name "Woodpecker" was published on Sep 9, 2019 ([v0.8.104](https://github.com/woodpecker-ci/woodpecker/releases/tag/v0.8.104)). ## Differences to Drone Woodpecker is a community-focused software that still stay free and open source forever, while Drone is managed by [Harness](https://harness.io/) and published under [Polyform Small Business](https://polyformproject.org/licenses/small-business/1.0.0/) license. ================================================ FILE: docs/versioned_docs/version-2.8/91-migrations.md ================================================ # Migrations Some versions need some changes to the server configuration or the pipeline configuration files. ## `next` - Deprecated `gated` repo settings option, use `require-approval` - Deprecated `steps.[name].group` in favor of `steps.[name].depends_on` (see [workflow syntax](./20-usage/20-workflow-syntax.md#depends_on) to learn how to set dependencies) - Removed `WOODPECKER_ROOT_PATH` and `WOODPECKER_ROOT_URL` config variables. Use `WOODPECKER_HOST` with a path instead - Pipelines without a config file will now be skipped instead of failing - Deprecated `includes` and `excludes` support from **event** filter - Deprecated uppercasing all secret env vars, instead, the value of the `secrets` property is used. [Read more](./20-usage/40-secrets.md#use-secrets-in-commands) - Deprecated alternative names for secrets, use `environment` with `from_secret` - Deprecated slice definition for env vars - Deprecated `environment` filter, use `when.evaluate` - Use `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` instead of `WOODPECKER_DEV_GITEA_OAUTH_URL` or `WOODPECKER_DEV_OAUTH_HOST` - Deprecated `WOODPECKER_WEBHOOK_HOST` in favor of `WOODPECKER_EXPERT_WEBHOOK_HOST` ## 2.0.0 - Dropped deprecated `CI_BUILD_*`, `CI_PREV_BUILD_*`, `CI_JOB_*`, `*_LINK`, `CI_SYSTEM_ARCH`, `CI_REPO_REMOTE` built-in environment variables - Deprecated `platform:` filter in favor of `labels:`, [read more](./20-usage/20-workflow-syntax.md#filter-by-platform) - Secrets `event` property was renamed to `events` and `image` to `images` as both are lists. The new property `events` / `images` has to be used in the api. The old properties `event` and `image` were removed. - The secrets `plugin_only` option was removed. Secrets with images are now always only available for plugins using listed by the `images` property. Existing secrets with a list of `images` will now only be available to the listed images if they are used as a plugin. - Removed `build` alias for `pipeline` command in CLI - Removed `ssh` backend. Use an agent directly on the SSH machine using the `local` backend. - Removed `/hook` and `/stream` API paths in favor of `/api/(hook|stream)`. You may need to use the "Repair repository" button in the repo settings or "Repair all" in the admin settings to recreate the forge hook. - Removed `WOODPECKER_DOCS` config variable - Renamed `link` to `url` (including all API fields) - Deprecated `CI_COMMIT_URL` env var, use `CI_PIPELINE_FORGE_URL` ## 1.0.0 - The signature used to verify extension calls (like those used for the [config-extension](./30-administration/40-advanced/100-external-configuration-api.md)) done by the Woodpecker server switched from using a shared-secret HMac to an ed25519 key-pair. Read more about it at the [config-extensions](./30-administration/40-advanced/100-external-configuration-api.md) documentation. - Refactored support for old agent filter labels and expressions. Learn how to use the new [filter](./20-usage/20-workflow-syntax.md#labels) - Renamed step environment variable `CI_SYSTEM_ARCH` to `CI_SYSTEM_PLATFORM`. Same applies for the cli exec variable. - Renamed environment variables `CI_BUILD_*` and `CI_PREV_BUILD_*` to `CI_PIPELINE_*` and `CI_PREV_PIPELINE_*`, old ones are still available but deprecated - Renamed environment variables `CI_JOB_*` to `CI_STEP_*`, old ones are still available but deprecated - Renamed environment variable `CI_REPO_REMOTE` to `CI_REPO_CLONE_URL`, old is still available but deprecated - Renamed environment variable `*_LINK` to `*_URL`, old ones are still available but deprecated - Renamed API endpoints for pipelines (`//builds/` -> `//pipelines/`), old ones are still available but deprecated - Updated Prometheus gauge `build_*` to `pipeline_*` - Updated Prometheus gauge `*_job_*` to `*_step_*` - Renamed config env `WOODPECKER_MAX_PROCS` to `WOODPECKER_MAX_WORKFLOWS` (still available as fallback) - The pipelines are now also read from `.yaml` files, the new default order is `.woodpecker/*.yml` and `.woodpecker/*.yaml` (without any prioritization) -> `.woodpecker.yml` -> `.woodpecker.yaml` - Dropped support for [Coding](https://coding.net/), [Gogs](https://gogs.io) and Bitbucket Server (Stash). - `/api/queue/resume` & `/api/queue/pause` endpoint methods were changed from `GET` to `POST` - rename `pipeline:` key in your workflow config to `steps:` - If you want to migrate old logs to the new format, watch the error messages on start. If there are none we are good to go, else you have to plan a migration that can take hours. Set `WOODPECKER_MIGRATIONS_ALLOW_LONG` to true and let it run. - Using `repo-id` in favor of `owner/repo` combination - :warning: The api endpoints `/api/repos/{owner}/{repo}/...` were replaced by new endpoints using the repos id `/api/repos/{repo-id}` - To find the id of a repo use the `/api/repos/lookup/{repo-full-name-with-slashes}` endpoint. - The existing badge endpoint `/api/badges/{owner}/{repo}` will still work, but whenever possible try to use the new endpoint using the `repo-id`: `/api/badges/{repo-id}`. - The UI urls for a repository changed from `/repos/{owner}/{repo}/...` to `/repos/{repo-id}/...`. You will be redirected automatically when using the old url. - The woodpecker-go api-client is now using the `repo-id` instead of `owner/repo` for all functions - Using `org-id` in favour of `owner` name - :warning: The api endpoints `/api/orgs/{owner}/...` were replaced by new endpoints using the orgs id `/api/repos/{org-id}` - To find the id of orgs use the `/api/orgs/lookup/{org_full_name}` endpoint. - The UI urls for a organization changed from `/org/{owner}/...` to `/orgs/{org-id}/...`. You will be redirected automatically when using the old url. - The woodpecker-go api-client is now using the `org-id` instead of `org name` for all functions - The `command:` field has been removed from steps. If you were using it, please check if the entrypoint of the image you used is a shell. - If it is a shell, simply rename `command:` to `commands:`. - If it's not, you need to prepend the entrypoint before and also rename it (e.g., `commands: `). ## 0.15.0 - Default value for custom pipeline path is now empty / un-set which results in following resolution: `.woodpecker/*.yml` -> `.woodpecker.yml` -> `.drone.yml` Only projects created after updating will have an empty value by default. Existing projects will stick to the current pipeline path which is `.drone.yml` in most cases. Read more about it at the [Project Settings](./20-usage/75-project-settings.md#pipeline-path) - From version `0.15.0` ongoing there will be three types of docker images: `latest`, `next` and `x.x.x` with an alpine variant for each type like `latest-alpine`. If you used `latest` before to try pre-release features you should switch to `next` after this release. - Dropped support for `DRONE_*` environment variables. The according `WOODPECKER_*` variables must be used instead. Additionally some alternative namings have been removed to simplify maintenance: - `WOODPECKER_AGENT_SECRET` replaces `WOODPECKER_SECRET`, `DRONE_SECRET`, `WOODPECKER_PASSWORD`, `DRONE_PASSWORD` and `DRONE_AGENT_SECRET`. - `WOODPECKER_HOST` replaces `DRONE_HOST` and `DRONE_SERVER_HOST`. - `WOODPECKER_DATABASE_DRIVER` replaces `DRONE_DATABASE_DRIVER` and `DATABASE_DRIVER`. - `WOODPECKER_DATABASE_DATASOURCE` replaces `DRONE_DATABASE_DATASOURCE` and `DATABASE_CONFIG`. - Dropped support for `DRONE_*` environment variables in pipeline steps. Pipeline meta-data can be accessed with `CI_*` variables. - `CI_*` prefix replaces `DRONE_*` - `CI` value is now `woodpecker` - `DRONE=true` has been removed - Some variables got deprecated and will be removed in future versions. Please migrate to the new names. Same applies for `DRONE_` of them. - CI_ARCH => use CI_SYSTEM_ARCH - CI_COMMIT => CI_COMMIT_SHA - CI_TAG => CI_COMMIT_TAG - CI_PULL_REQUEST => CI_COMMIT_PULL_REQUEST - CI_REMOTE_URL => use CI_REPO_REMOTE - CI_REPO_BRANCH => use CI_REPO_DEFAULT_BRANCH - CI_PARENT_BUILD_NUMBER => use CI_BUILD_PARENT - CI_BUILD_TARGET => use CI_BUILD_DEPLOY_TARGET - CI_DEPLOY_TO => use CI_BUILD_DEPLOY_TARGET - CI_COMMIT_AUTHOR_NAME => use CI_COMMIT_AUTHOR - CI_PREV_COMMIT_AUTHOR_NAME => use CI_PREV_COMMIT_AUTHOR - CI_SYSTEM => use CI_SYSTEM_NAME - CI_BRANCH => use CI_COMMIT_BRANCH - CI_SOURCE_BRANCH => use CI_COMMIT_SOURCE_BRANCH - CI_TARGET_BRANCH => use CI_COMMIT_TARGET_BRANCH For all available variables and their descriptions have a look at [built-in-environment-variables](./20-usage/50-environment.md#built-in-environment-variables). - Prometheus metrics have been changed from `drone_*` to `woodpecker_*` - Base path has moved from `/var/lib/drone` to `/var/lib/woodpecker` - Default workspace base path has moved from `/drone` to `/woodpecker` - Default SQLite database location has changed: - `/var/lib/drone/drone.sqlite` -> `/var/lib/woodpecker/woodpecker.sqlite` - `drone.sqlite` -> `woodpecker.sqlite` - Plugin Settings moved into `settings` section: ```diff steps: something: image: my/plugin - setting1: foo - setting2: bar + settings: + setting1: foo + setting2: bar ``` - `WOODPECKER_DEBUG` option for server and agent got removed in favor of `WOODPECKER_LOG_LEVEL=debug` - Remove unused server flags which can safely be removed from your server config: `WOODPECKER_QUIC`, `WOODPECKER_GITHUB_SCOPE`, `WOODPECKER_GITHUB_GIT_USERNAME`, `WOODPECKER_GITHUB_GIT_PASSWORD`, `WOODPECKER_GITHUB_PRIVATE_MODE`, `WOODPECKER_GITEA_GIT_USERNAME`, `WOODPECKER_GITEA_GIT_PASSWORD`, `WOODPECKER_GITEA_PRIVATE_MODE`, `WOODPECKER_GITLAB_GIT_USERNAME`, `WOODPECKER_GITLAB_GIT_PASSWORD`, `WOODPECKER_GITLAB_PRIVATE_MODE` - Dropped support for manually setting the agents platform with `WOODPECKER_PLATFORM`. The platform is now automatically detected. - Use `WOODPECKER_STATUS_CONTEXT` instead of the deprecated options `WOODPECKER_GITHUB_CONTEXT` and `WOODPECKER_GITEA_CONTEXT`. ## 0.14.0 No breaking changes ## From Drone :::warning Migration from Drone is only possible if you were running Drone <= v0.8. ::: 1. Make sure you are already running Drone v0.8 2. Upgrade to Woodpecker v0.14.4, migration will be done during startup 3. Upgrade to the latest Woodpecker version. Pay attention to the breaking changes listed above. ================================================ FILE: docs/versioned_docs/version-2.8/92-awesome.md ================================================ # Awesome Woodpecker A curated list of awesome things related to Woodpecker CI. If you have some missing resources, please feel free to [open a pull-request](https://github.com/woodpecker-ci/woodpecker/edit/main/docs/docs/92-awesome.md) and add them. ## Official Resources - [Woodpecker CI pipeline configs](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) - Complex setup containing different kind of pipelines - [Golang tests](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/test.yaml) - [Typescript, eslint & Vue](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/web.yaml) - [Docusaurus & publishing to GitHub Pages](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docs.yaml) - [Docker container building](https://github.com/woodpecker-ci/woodpecker/blob/main/.woodpecker/docker.yaml) ## Projects using Woodpecker - [Woodpecker CI](https://github.com/woodpecker-ci/woodpecker/tree/main/.woodpecker) itself - [All official plugins](https://github.com/woodpecker-ci?q=plugin&type=all) - [dessalines/thumb-key](https://github.com/dessalines/thumb-key/blob/main/.woodpecker.yml) - Android Jetpack compose linting and building - [Vieter](https://git.rustybever.be/vieter-v/vieter) - Archlinux/Pacman repository server & automated package build system - [Rieter](https://git.rustybever.be/Chewing_Bever/rieter) - Rewrite of the Vieter project in Rust - [Alex](https://git.rustybever.be/Chewing_Bever/alex) - Minecraft server wrapper designed to automate backups & complement Docker installations ## Tools - [Convert Drone CI pipelines to Woodpecker CI](https://codeberg.org/lafriks/woodpecker-pipeline-transform) - [Ansible NAS](https://github.com/davestephens/ansible-nas/) - a homelab Ansible playbook that can set up Woodpecker CI and Gitea - [picus](https://github.com/windsource/picus) - Picus connects to a Woodpecker CI server and creates an agent in the cloud when there are pending workflows. - [Hetzner cloud](https://www.hetzner.com/cloud) based [Woodpecker compatible autoscaler](https://git.ljoonal.xyz/ljoonal/hetzner-ci-autoscaler) - Creates and destroys VPS instances based on the count of pending & running jobs. - [woodpecker-lint](https://git.schmidl.dev/schtobia/woodpecker-lint) - A repository for linting a Woodpecker config file via pre-commit hook - [Grafana Dashboard](https://github.com/Janik-Haag/woodpecker-grafana-dashboard) - A dashboard visualizing information exposed by the Woodpecker prometheus endpoint. - [woodpecker-autoscaler](https://github.com/Lerentis/woodpecker-autoscaler) - Yet another Woodpecker autoscaler currently targeting [Hetzner cloud](https://www.hetzner.com/cloud) that works in parallel to other autoscaler implementations. ## Configuration Services - [Dynamic Pipelines for Nix Flakes](https://github.com/pinpox/woodpecker-flake-pipeliner) - Define pipelines as Nix Flake outputs ## Pipelines - [Collection of pipeline examples](https://codeberg.org/Codeberg-CI/examples) ## Posts & tutorials - [Setup Gitea with Woodpecker CI](https://containers.fan/posts/setup-gitea-with-woodpecker-ci/) - [Step-by-step guide to modern, secure and Open-source CI setup](https://devforth.io/blog/step-by-step-guide-to-modern-secure-ci-setup/) - [Using Woodpecker CI for my static sites](https://jan.wildeboer.net/2022/07/Woodpecker-CI-Jekyll/) - [Woodpecker CI @ Codeberg](https://www.sarkasti.eu/articles/post/woodpecker/) - [Deploy Docker/Compose using Woodpecker CI](https://hinty.io/vverenko/deploy-docker-compose-using-woodpecker-ci/) - [Installing Woodpecker CI in your personal homelab](https://pwa.io/articles/installing-woodpecker-in-your-homelab/) - [Locally Cached Nix CI with Woodpecker](https://blog.kotatsu.dev/posts/2023-04-21-woodpecker-nix-caching/) - [How to run Cypress auto-tests on Woodpecker CI and report results to Slack](https://devforth.io/blog/how-to-run-cypress-auto-tests-on-woodpecker-ci-and-report-results-to-slack/) - [Quest For CICD - WoodpeckerCI](https://omaramin.me/posts/woodpecker/) - [Getting started with Woodpecker CI](https://systeemkabouter.eu/getting-started-with-woodpecker-ci.html) - [Installing gitea and woodpecker using binary packages](https://neelex.com/2023/03/26/Installing-gitea-using-binary-packages/) - [Deploying mdbook to codeberg pages using woodpecker CI](https://www.markpitblado.me/blog/deploying-mdbook-to-codeberg-pages-using-woodpecker-ci/) - [Deploy a Fly app with Woodpecker CI](https://joeroe.io/2024/01/09/deploy-fly-woodpecker-ci.html) - [Ansible - using Woodpecker as an alternative to Semaphore](https://pat-s.me/ansible-using-woodpecker-as-an-alternative-to-semaphore/) ## Videos - [Replace Ansible Semaphore with Woodpecker CI](https://www.youtube.com/watch?v=d610YPvCB0E) - ["unexpected EOF" error when trying to pair Woodpecker CI served through the Caddy with Gitea](https://www.youtube.com/watch?v=n7Hyvt71Np0) - [CICD Environment in Docker Swarm behind Caddy Server - Part 2 Woodpeckerci](https://www.youtube.com/watch?v=rkbw_k7JvS0) ## Plugins We have a separate [index](/plugins) for plugins. ================================================ FILE: docs/versioned_docs/version-2.8/92-development/01-getting-started.md ================================================ # Getting started You can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea). ## Gitpod If you want to start development or updating docs as easy as possible, you can use our preconfigured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing: - An IDE in the browser or bridged to your local VS-Code or Jetbrains - A preconfigured [Gitea](https://github.com/go-gitea/gitea) instance as forge - A preconfigured Woodpecker server - A single preconfigured Woodpecker agent node - Our docs preview server Start Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`. [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker) ## Preparation for local development ### Install Go Install Golang (>=1.20) as described by [this guide](https://go.dev/doc/install). ### Install make > GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (). Install make on: - Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/) - [Windows](https://stackoverflow.com/a/32127632/8461267) - Mac OS: `brew install make` ### Install Node.js & `pnpm` Install [Node.js (>=14)](https://nodejs.org/en/download/) if you want to build Woodpecker's UI or documentation. For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used. [This guide](https://pnpm.io/installation) describes the installation of `pnpm`. ### Install `pre-commit` (optional) Woodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code. To apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage). ### Create a `.env` file with your development configuration Similar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it. A common config for debugging would look like this: ```ini WOODPECKER_OPEN=true WOODPECKER_ADMIN=your-username # if you want to test webhooks with an online forge like GitHub this address needs to be accessible from public server WOODPECKER_HOST=http://your-dev-address.com # github (sample for a forge config - see /docs/administration/forge/overview for other forges) WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT= WOODPECKER_GITHUB_SECRET= # agent WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system WOODPECKER_MAX_WORKFLOWS=1 # enable if you want to develop the UI # WOODPECKER_DEV_WWW_PROXY=http://localhost:8010 # used so you can login without using a public address WOODPECKER_DEV_OAUTH_HOST=http://localhost:8000 # disable health-checks while debugging (normally not needed while developing) WOODPECKER_HEALTHCHECK=false # WOODPECKER_LOG_LEVEL=debug # WOODPECKER_LOG_LEVEL=trace ``` ### Setup OAuth Create an OAuth app for your forge as described in the [forges documentation](../30-administration/11-forges/11-overview.md). If you set `WOODPECKER_DEV_OAUTH_HOST=http://localhost:8000` you can use that address with the path as explained for the specific forge to login without the need for a public address. For example for GitHub you would use `http://localhost:8000/authorize` as authorization callback URL. ## Developing with VS Code You can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it. To launch all needed services for local development, you can use "Woodpecker CI" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it. As a starting guide for programming Go with VS Code, you can use this video guide: [![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80) ### Debugging Woodpecker The Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points. ![Woodpecker debugging with VS Code](./vscode-debug.png) ## Testing & linting code To test or lint parts of Woodpecker, you can run one of the following commands: ```bash # test server code make test-server # test agent code make test-agent # test cli code make test-cli # test datastore / database related code like migrations of the server make test-server-datastore # lint go code make lint # lint UI code make lint-frontend # test UI code make test-frontend ``` If you want to test a specific Go file, you can also use: ```bash go test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v2/ ``` Or you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands: ![Run test via VS-Code](./vscode-run-test.png) ## Run applications from terminal If you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor. ```bash title="start server" go run ./cmd/server ``` ```bash title="start agent" go run ./cmd/agent ``` ```bash title="execute cli command" go run ./cmd/cli [command] ``` ================================================ FILE: docs/versioned_docs/version-2.8/92-development/02-core-ideas.md ================================================ # Core ideas - A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂). - If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle). - What is used most often should be default. - Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md). ## Addons and extensions If you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an [addon forge](../30-administration/11-forges/100-addon.md), [extension](../30-administration/40-advanced/100-external-configuration-api.md) or an [external custom backend](../30-administration/22-backends/50-custom-backends.md), please check these points: - Is your change very specific to your setup and unlikely to be used by anyone else? - Does your change violate the [guidelines](#guidelines)? Both should be false when you open a pull request to get your change into the core repository. ### Guidelines #### Forges A new forge must support these features: - OAuth2 - Webhooks ================================================ FILE: docs/versioned_docs/version-2.8/92-development/03-ui.md ================================================ # UI Development To develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api. ## Setup The UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed). Testing UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files. ![UI Proxy architecture](./ui-proxy.svg) Start the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file. After starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000). ## Tools and frameworks The following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing. - [Vue 3](https://v3.vuejs.org/) - use `setup` and composition api - place (re-usable) components in `web/src/components/` - views should have a route in `web/src/router.ts` and are located in `web/src/views/` - [Windicss](https://windicss.org/) (similar to Tailwind) - use Windicss classes where possible - if needed extend the Windicss config to use new classes - [Vite](https://vitejs.dev/) (similar to Webpack) - [Typescript](https://www.typescriptlang.org/) - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:) - [eslint](https://eslint.org/) - [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471) ## Messages and Translations Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source. You must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet) For more information about translations see [Translations](./08-translations.md). ================================================ FILE: docs/versioned_docs/version-2.8/92-development/04-docs.md ================================================ # Documentation The documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/). If you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands: ```bash cd docs/ pnpm install # build plugins used by the docs pnpm build:woodpecker-plugins # start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually pnpm start # or build the docs to deploy it to some static page hosting pnpm build ``` ================================================ FILE: docs/versioned_docs/version-2.8/92-development/05-architecture.md ================================================ # Architecture ## Package architecture ![Woodpecker architecture](./woodpecker-architecture.png) ## System architecture ### main package hierarchy | package | meaning | imports | | ------------------ | -------------------------------------------------------------- | ------------------------------------- | | `cmd/**` | parse command-line args & environment to stat server/cli/agent | all other | | `agent/**` | code only agent (remote worker) will need | `pipeline`, `shared` | | `cli/**` | code only cli tool does need | `pipeline`, `shared`, `woodpecker-go` | | `server/**` | code only server will need | `pipeline`, `shared` | | `shared/**` | code shared for all three main tools (go help utils) | only std and external libs | | `woodpecker-go/**` | go client for server rest api | std | ### Server | package | meaning | imports | | -------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server/api/**` | handle web requests from `server/router` | `pipeline`, `../badges`, `../ccmenue`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) | | `server/badges/**` | generate svg badges for pipelines | `../model` | | `server/ccmenu/**` | generate xml ccmenu for pipelines | `../model` | | `server/grpc/**` | gRPC server agents can connect to | `pipeline/rpc/**`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store` | | `server/logging/**` | logging lib for gPRC server to stream logs while running | std | | `server/model/**` | structs for store (db) and api (json) | std | | `server/plugins/**` | plugins for server | `../model`, `../forge` | | `server/pipeline/**` | orchestrate pipelines | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins` | | `server/pubsub/**` | pubsub lib for server to push changes to the WebUI | std | | `server/queue/**` | queue lib for server where agents pull new pipelines from via gRPC | `server/model` | | `server/forge/**` | forge lib for server to connect and handle forge specific stuff | `shared`, `server/model` | | `server/router/**` | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web` | | `server/store/**` | handle database | `server/model` | | `server/shared/**` | TODO: move and split [#974](https://github.com/woodpecker-ci/woodpecker/issues/974) | | | `server/web/**` | server SPA | | - `../` = `server/` ### Agent TODO ### CLI TODO ================================================ FILE: docs/versioned_docs/version-2.8/92-development/06-conventions.md ================================================ # Conventions ## Database naming Database tables are named plural, columns don't have any prefix. Example: Table name `agent`, columns `id`, `name`. ================================================ FILE: docs/versioned_docs/version-2.8/92-development/07-guides.md ================================================ # Guides ## ORM Woodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection. ## Add a new migration Woodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`. :::info Adding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created. ::: :::warning You should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager. ::: To automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start. ## Constants of official images All official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag. ================================================ FILE: docs/versioned_docs/version-2.8/92-development/08-translations.md ================================================ # Translations To translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.** Translation status Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. ================================================ FILE: docs/versioned_docs/version-2.8/92-development/09-swagger.md ================================================ # Swagger, API Spec and Code Generation Woodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically generate Swagger v2 API specifications and a nice looking Web UI from the source code. Also, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger) and then being using on the community's website documentation. It's paramount important to keep the gin handler function's godoc documentation up-to-date, to always have accurate API documentation. Whenever you change, add or enhance an API endpoint, please update the godocs. You don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools. ## Gin-Handler API documentation guideline Here's a typical example of how annotations for Swagger documentation look like... ```go title="server/api/user.go" // @Summary Get a user // @Description Returns a user with the specified login name. Requires admin rights. // @Router /users/{login} [get] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param foobar query string false "optional foobar parameter" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) ``` ```go title="server/model/user.go" type User struct { ID int64 `json:"id" xorm:"pk autoincr 'user_id'"` // ... } // @name User ``` These guidelines aim to have consistent wording in the swagger doc: - first word after `@Summary` and `@Summary` are always uppercase - `@Summary` has no `.` (dot) at the end of the line - model structs shall use custom short names, to ease life for API consumers, using `@name` - `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be renderend in Swagger - when pagination is used, `@Parame page` and `@Parame perPage` must be added manually - `@Param Authorization` is almost always present, there are just a few un-protected endpoints There are many examples in the `server/api` package, which you can use a blueprint. More enhanced information you can find here ### Manual code generation ```bash title="generate the server's Go code containing the Swagger" make generate-swagger ``` ```bash title="update the Markdown in the ./docs folder" make generate-docs ``` ```bash title="auto-format swagger related godoc" go run github.com/swaggo/swag/cmd/swag@latest fmt -g server/api/z.go ``` ================================================ FILE: docs/versioned_docs/version-2.8/92-development/09-testing.md ================================================ # Testing ## Backend ### Unit Tests [We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test) with [`"github.com/stretchr/testify/assert"`](https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert) to simplify testing. ### Integration Tests ### Dummy backend There is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave. To enable it you need to build the agent or cli with the `test` build tag. An example pipeline config would be: ```yaml when: event: manual steps: - name: echo image: dummy commands: echo "hello woodpecker" environment: SLEEP: '1s' services: echo: image: dummy commands: echo "i am a sevice" ``` This could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`: ```none 9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: service [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9 [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: commands [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 ``` There are also environment variables to alter step behaviour: - `SLEEP: 10` will let the step wait 10 seconds - `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands` - `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled) - `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs - `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0 - `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains You can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`. ================================================ FILE: docs/versioned_docs/version-2.8/92-development/_category_.yaml ================================================ label: 'Development' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.12/10-intro/index.md ================================================ # Welcome to Woodpecker Woodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics. ## Have you ever heard of CI/CD or pipelines? Don't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of checks, tests and routines along the way. A typical pipeline might include the following steps: 1. Running tests 2. Building your application 3. Deploying your application [Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd) ## Do you know containers? If you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/). ## Already have access to a Woodpecker instance? Then you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md). ## Want to start from scratch and deploy your own Woodpecker instance? Woodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance. ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/10-intro.md ================================================ # Your first pipeline Let's get started and create your first pipeline. ## 1. Repository Activation To activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click. ![new repository list](repo-new.png) To enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something that is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.). ## 2. Define first workflow After enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository: ```yaml title=".woodpecker/my-first-workflow.yaml" when: - event: push branch: main steps: - name: build image: debian commands: - echo "This is the build step" - echo "binary-data-123" > executable - name: a-test-step image: golang:1.16 commands: - echo "Testing ..." - ./executable ``` **So what did we do here?** 1. We defined your first workflow file `my-first-workflow.yaml`. 2. This workflow will be executed when a push event happens on the `main` branch, because we added a filter using the `when` section: ```diff + when: + - event: push + branch: main ... ``` 3. We defined two steps: `build` and `a-test-step` The steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`. In the `build` step we use the `debian` image and build a "binary file" called `executable`. In the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it. You can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to: ```diff steps: - name: build - image: debian + image: my-company/image-with-aws_cli commands: - aws help ``` ## 3. Push the file and trigger first pipeline If you push this file to your repository now, Woodpecker will already execute your first pipeline. You can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository. ![pipeline view](./pipeline.png) As you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps. This for example allows the first step to build your application using your source code and as the second step will receive the same workspace it can use the previously built binary and test it. ## 4. Use a plugin for reusable tasks Sometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md). If you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline: ```yaml steps: # ... - name: upload image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name access_key: a50d28f4dd477bc184fbd10b376de753 secret_key: from_secret: aws_secret_key source: public/**/* target: /target/location ``` To configure a plugin you can use the `settings` section. Sometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md). Similar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed. Learn more about [plugins](./51-plugins/51-overview.md). As you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md). ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/100-troubleshooting.md ================================================ # Troubleshooting ## How to debug clone issues (And what to do with an error message like `fatal: could not read Username for 'https://': No such device or address`) This error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`: ```ini WOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true ``` If that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container "hang": ```yaml skip_clone: true steps: build: image: debian:stable-backports commands: - apt update - apt install -y inetutils-ping wget - ping -c 4 git.example.com - wget git.example.com - sleep 9999999 ``` Get the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline: ```bash git init git remote add origin https://git.example.com/username/repo.git git fetch --no-tags origin +refs/heads/branch: ``` (replace the url AND the branch with the correct values, use your username and password as log in values) ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/15-terminology/architecture.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 226, "versionNonce": 1002880859, "isDeleted": false, "id": "UczUX5VuNnCB1rVvUJVfm", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.098092529257, "y": 320.8758615860986, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 472.8823858375721, "height": 183.19688715994928, "seed": 917720693, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 161, "versionNonce": 286006267, "isDeleted": false, "id": "sKPZmBSWUdAYfBs4ByItH", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 539.5451038202509, "y": 345.2419383247636, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 82.46875, "height": 32.199999999999996, "seed": 1485551573, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Server", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Server", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 333, "versionNonce": 448586907, "isDeleted": false, "id": "_A8uznhnpXuQBYzjP-iVx", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 649.8080506852966, "y": 427.60908869342575, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 136, "height": 60, "seed": 1783625013, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "r90dckf8trHemYzEwCgCW" }, { "id": "XxfJWnHonmvNOJzMFSlie", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 298, "versionNonce": 1244067771, "isDeleted": false, "id": "r90dckf8trHemYzEwCgCW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 703.8080506852966, "y": 441.5090886934257, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 28, "height": 32.199999999999996, "seed": 660965013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113383, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "UI", "textAlign": "center", "verticalAlign": "middle", "containerId": "_A8uznhnpXuQBYzjP-iVx", "originalText": "UI", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 105, "versionNonce": 265992667, "isDeleted": false, "id": "v2eEwSOSRQBZ79O6wyzGf", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 800.9240766836483, "y": 421.4987043996123, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 135.3671503686619, "height": 62.2689029398432, "seed": 1115810805, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "svsVhxCbatcLj7lQLch0P" }, { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 83, "versionNonce": 1706870395, "isDeleted": false, "id": "svsVhxCbatcLj7lQLch0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 828.1594096804793, "y": 436.53315586953386, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 80.896484375, "height": 32.199999999999996, "seed": 2074781013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "GRPC", "textAlign": "center", "verticalAlign": "middle", "containerId": "v2eEwSOSRQBZ79O6wyzGf", "originalText": "GRPC", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 270, "versionNonce": 418660123, "isDeleted": false, "id": "hSrrwwnm9y7R-_CnJtaK1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.567103519039, "y": 556.4146894573112, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 1983197877, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 154, "versionNonce": 871605179, "isDeleted": false, "id": "8tsYgVssKnBd_Zw1QuqNz", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1298.4367898442752, "y": 566.567242947784, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 96.5234375, "height": 32.199999999999996, "seed": 1321669653, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent 1", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent 1", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 182, "versionNonce": 1323136091, "isDeleted": false, "id": "eeugZg73_yD_6uLBBgmcX", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 404.5001910129067, "y": 707.1233710221009, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 210.068359375, "height": 32.199999999999996, "seed": 1901447541, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "User => Browser", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "User => Browser", "lineHeight": 1.15, "baseline": 25 }, { "type": "ellipse", "version": 106, "versionNonce": 1501835515, "isDeleted": false, "id": "mlDhl4OOc-H1tNgh77AAW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 482.5857164810477, "y": 602.4394551739279, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 46.024748503793035, "height": 44.21988070606176, "seed": 791073493, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "line", "version": 166, "versionNonce": 627726747, "isDeleted": false, "id": "ADEXzdYAhvj-_wVRftTIg", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 459.12202200277807, "y": 697.1964604319912, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 80.31792517362464, "height": 31.585599568061298, "seed": 349155381, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": null, "points": [ [ 0, 0 ], [ 42.415150610916044, -28.87829787146393 ], [ 80.31792517362464, 2.7073016965973693 ] ] }, { "type": "rectangle", "version": 231, "versionNonce": 801271355, "isDeleted": false, "id": "xmz4J-rxLIjfUQ4q19PjD", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 516.8788931508789, "y": 870.4664542146543, "strokeColor": "#f08c00", "backgroundColor": "#fff4e6", "width": 385.34512717560705, "height": 60.464035142111264, "seed": 3531157, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 93, "versionNonce": 728690395, "isDeleted": false, "id": "gSbpry_947XArfI7b6AAL", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 636.1468430141358, "y": 878.5884970070326, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 132.2890625, "height": 32.199999999999996, "seed": 1989076725, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Autoscaler", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Autoscaler", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 118, "versionNonce": 1258445691, "isDeleted": false, "id": "WVy0mdTGbUx08RuxdQUH8", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 523.3741602213286, "y": 907.372811672524, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 369.1484375, "height": 18.4, "seed": 979386453, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Starts agents based on amount of pending pipelines", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Starts agents based on amount of pending pipelines", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 373, "versionNonce": 1254044699, "isDeleted": false, "id": "0Y1RcqzVFBFqh-wy-APMI", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1232.1955835481922, "y": 605.8737363119278, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 292.6171875, "height": 18.4, "seed": 561999285, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Executes pending workflows of a pipeline", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Executes pending workflows of a pipeline", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 630, "versionNonce": 983038139, "isDeleted": false, "id": "lGumbhMs3xx1vU2632hli", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 505.62283787078286, "y": 383.42044095379515, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 158.015625, "height": 36.8, "seed": 722595605, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Central unit of a \nWoodpecker instance ", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Central unit of a \nWoodpecker instance ", "lineHeight": 1.15, "baseline": 32 }, { "type": "rectangle", "version": 131, "versionNonce": 137308507, "isDeleted": false, "id": "PbSQXehWVLYcQGXYFpd-B", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 971.7123256059622, "y": 171.06951064323448, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 274.3443117379593, "height": 74.90311522655017, "seed": 1435321461, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 96, "versionNonce": 1222067707, "isDeleted": false, "id": "2P2tz29C_2sUzVNSpaG17", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.5206131439782, "y": 183.12082907329545, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 73.14453125, "height": 32.199999999999996, "seed": 884403669, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Forge", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Forge", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 141, "versionNonce": 1133694619, "isDeleted": false, "id": "0eYhFYPuRanZ7wkR2OlHO", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 986.864582863368, "y": 225.1223531590797, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 247.234375, "height": 18.4, "seed": 1201957685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "HK1jmIcPmM6Us6Jrynobb", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Github, Gitea, Github, Bitbucket, ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Github, Gitea, Github, Bitbucket, ...", "lineHeight": 1.15, "baseline": 14 }, { "type": "rectangle", "version": 55, "versionNonce": 991183675, "isDeleted": false, "id": "dihpRzuIc-UoRSsOI33SZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 820.419424341303, "y": 340.29123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 247151765, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "bcUL-u4zkLA9CLG2YdaeN" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 38, "versionNonce": 2008949723, "isDeleted": false, "id": "bcUL-u4zkLA9CLG2YdaeN", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 831.853994653803, "y": 358.79123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 94.130859375, "height": 23, "seed": 1638982133, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Webhooks", "textAlign": "center", "verticalAlign": "middle", "containerId": "dihpRzuIc-UoRSsOI33SZ", "originalText": "Webhooks", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 93, "versionNonce": 295891067, "isDeleted": false, "id": "Bphhue86mMXHN4klGamM3", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 697.3018309300141, "y": 339.607928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 92986197, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "0YxY2hEPyDWFqR8_-f6bn" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 87, "versionNonce": 2055547163, "isDeleted": false, "id": "0YxY2hEPyDWFqR8_-f6bn", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 727.4522215550141, "y": 358.107928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 56.69921875, "height": 23, "seed": 43952309, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "OAuth", "textAlign": "center", "verticalAlign": "middle", "containerId": "Bphhue86mMXHN4klGamM3", "originalText": "OAuth", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 284, "versionNonce": 1205292475, "isDeleted": false, "id": "HK1jmIcPmM6Us6Jrynobb", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1205.6453201409104, "y": 250.4849674923464, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 272.1094712799886, "height": 94.31865813977868, "seed": 982632981, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "uDIWJ5K5mEBL9QaiNk3cS" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "0eYhFYPuRanZ7wkR2OlHO", "focus": -0.8418551162334328, "gap": 6.962614333266799 }, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ -69.68740859223726, 65.87860410965993 ], [ -272.1094712799886, 94.31865813977868 ] ] }, { "type": "text", "version": 53, "versionNonce": 1803962459, "isDeleted": false, "id": "uDIWJ5K5mEBL9QaiNk3cS", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1050.575099048673, "y": 297.96357160200637, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 170.765625, "height": 36.8, "seed": 1046069109, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "sends events like push, \ntag, ...", "textAlign": "center", "verticalAlign": "middle", "containerId": "HK1jmIcPmM6Us6Jrynobb", "originalText": "sends events like push, tag, ...", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 487, "versionNonce": 335895291, "isDeleted": false, "id": "Kqbwk_qfkALJfhtCIr2eS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 792.0835609101814, "y": 316.38601649373913, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 176.92139414789008, "height": 122.73778943055902, "seed": 1681656021, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yvJTQ64RU50N6-hxEQlkl" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "UczUX5VuNnCB1rVvUJVfm", "focus": -0.03867359238356983, "gap": 4.489845092359474 }, "endBinding": { "elementId": "PbSQXehWVLYcQGXYFpd-B", "focus": 0.7798878042817562, "gap": 2.707370547890605 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 60.422360349016344, -71.97786730696657 ], [ 176.92139414789008, -122.73778943055902 ] ] }, { "type": "text", "version": 62, "versionNonce": 301106427, "isDeleted": false, "id": "yvJTQ64RU50N6-hxEQlkl", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 773.7910775091977, "y": 226.00814918677256, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 157.4296875, "height": 36.8, "seed": 500049461, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "allows users to login \nusing existing account", "textAlign": "center", "verticalAlign": "middle", "containerId": "Kqbwk_qfkALJfhtCIr2eS", "originalText": "allows users to login using existing account", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 393, "versionNonce": 598459861, "isDeleted": false, "id": "TvtonmlV0W8__pnTG-wVZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 936.9267543177084, "y": 458.95033086418084, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 215.17788326846676, "height": 93.99151368376693, "seed": 234198933, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "rFf6NIofw6UBOyAFwg0Kn" } ], "updated": 1697530127259, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.30339107267010673, "gap": 1 }, "endBinding": { "elementId": "hSrrwwnm9y7R-_CnJtaK1", "focus": -0.14057158065513534, "gap": 3.4728449093634026 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 130.0760301643047, 42.90930518030268 ], [ 215.17788326846676, 93.99151368376693 ] ] }, { "type": "text", "version": 8, "versionNonce": 1693330843, "isDeleted": false, "id": "rFf6NIofw6UBOyAFwg0Kn", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 997.4942845557462, "y": 473.9409015069133, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 1592253685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "TvtonmlV0W8__pnTG-wVZ", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 270, "versionNonce": 1855882619, "isDeleted": false, "id": "5tl702dfcvJDLz9aIFU0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 886.0581619083632, "y": 485.67004123832135, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 174.09447592006472, "height": 326.4905563076211, "seed": 1479177813, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "apyMCAv2GIN_yzHXwX4tY" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.1341191028023529, "gap": 1.9024338988657519 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "focus": -0.7088661407505865, "gap": 4.060573862784622 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 44.14165353942735, 196.18483635907205 ], [ 174.09447592006472, 326.4905563076211 ] ] }, { "type": "text", "version": 66, "versionNonce": 2007745083, "isDeleted": false, "id": "apyMCAv2GIN_yzHXwX4tY", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 849.4927841977906, "y": 663.4548775973934, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 882041781, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "5tl702dfcvJDLz9aIFU0P", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 347, "versionNonce": 1353818811, "isDeleted": false, "id": "XxfJWnHonmvNOJzMFSlie", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 534.9278465333664, "y": 595.2199151317081, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 113.88020415193023, "height": 119.81968366814112, "seed": 944153877, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "_A8uznhnpXuQBYzjP-iVx", "focus": 0.5397285671082249, "gap": 1 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 113.88020415193023, -119.81968366814112 ] ] }, { "type": "rectangle", "version": 61, "versionNonce": 1099141979, "isDeleted": false, "id": "j56ZKRwmXk72nHrZzLz_1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1081.8110514012087, "y": 652.5253283508498, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 566.7373014532342, "height": 68.58600908319681, "seed": 112933493, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 82, "versionNonce": 1879994363, "isDeleted": false, "id": "cAVYXfBRnfuGAv7QTQVow", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1300.6584159706863, "y": 658.8425033454967, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 77.83203125, "height": 23, "seed": 951460821, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Backend", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Backend", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 376,- add some images explaining the architecture & terminology with pipeline -> workflow -> step - combine advanced config usage - rename pipeline syntax to workflow syntax (and most references to pipeline steps etc as well) - update agent registration part - add bug note to secrets encryption setting - remove usage from readme to point to up-to-date docs page - typos - closes #1408 --------- "angle": 0, "x": 1094.1972977313717, "y": 681.8988272758752, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 530.9453125, "height": 55.199999999999996, "seed": 843899189, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "lineHeight": 1.15, "baseline": 50 }, { "type": "rectangle", "version": 384, "versionNonce": 1778969915, "isDeleted": false, "id": "pxF49EKDNO6IZq_34i7bY", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1064.2132116912126, "y": 754.5018564383092, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 954528405, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "arrow", "version": 154, "versionNonce": 1988988379, "isDeleted": false, "id": "05EJzh4NLXxemaKAmdi5n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 904.0288881242177, "y": 882.4966027880746, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 158.83070714434325, "height": 32.735025983189644, "seed": 1228134389, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yNxAOEPZu_Jl7mnI01OXs" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "xmz4J-rxLIjfUQ4q19PjD", "gap": 1.8048677977312764, "focus": 0.31250963573550006 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "gap": 1.353616422651612, "focus": 0.36496042109885213 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 158.83070714434325, -32.735025983189644 ] ] }, { "type": "text", "version": 25, "versionNonce": 1393410779, "isDeleted": false, "id": "yNxAOEPZu_Jl7mnI01OXs", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 963.8856479463893, "y": 856.9290897964797, "strokeColor": "#f08c00", "backgroundColor": "#b2f2bb", "width": 39.1171875, "height": 18.4, "seed": 759107925, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113387, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "starts", "textAlign": "center", "verticalAlign": "middle", "containerId": "05EJzh4NLXxemaKAmdi5n", "originalText": "starts", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 187, "versionNonce": 671224603, "isDeleted": false, "id": "sSj4Pda-fo-BBYM_dzml6", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1296.0854928322988, "y": 776.6118140041631, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 104.2890625, "height": 32.199999999999996, "seed": 1381768885, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent ...", "lineHeight": 1.15, "baseline": 25 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/15-terminology/index.md ================================================ # Terminology ## Glossary - **Woodpecker CI**: The project name around Woodpecker. - **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code. - **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration. - **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC. - **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines. - **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events. - **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker). - **Steps**: Individual commands, actions or tasks within a [workflow][Workflow]. - **Code**: Refers to the files tracked by the version control system used by the [forge][Forge]. - **Repos**: Short for repositories, these are storage locations where code is stored. - **[Forge][Forge]**: The hosting platform or service where the repositories are hosted. - **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps. - **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI. - **Commit**: A defined state of the code, usually associated with a version control system like Git. - **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix. - **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow]. - **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings. - **Container**: A lightweight and isolated environment where commands are executed. - **YAML File**: A file format used to define and configure [workflows][Workflow]. - **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel. - **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge]. - **Service extension**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through service extensions. - **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue. ## Woodpecker architecture ![Woodpecker architecture](architecture.svg) ## Pipeline, workflow & step ![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg) ## Conventions Sometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker: - Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()` - Use the term **pipelines** instead of the previous **builds** - Use the term **steps** instead of the previous **jobs** - Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users [Event]: ../20-workflow-syntax.md#event [Pipeline]: ../20-workflow-syntax.md [Workflow]: ../25-workflows.md [Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md [Plugin]: ../51-plugins/51-overview.md [Workspace]: ../20-workflow-syntax.md#workspace [Matrix]: ../30-matrix-workflows.md [Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md [Local]: ../../30-administration/10-configuration/11-backends/30-local.md ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/15-terminology/pipeline-workflow-step.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 97, "versionNonce": 257762037, "isDeleted": false, "id": "Y3hYdpX9r1qWfyHWs7AXT", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 393.622323134362, "y": 336.02197155458475, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 366.3936710429598, "height": 499.95605689083004, "seed": 875444373, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 67, "versionNonce": 369556565, "isDeleted": false, "id": "g1Eb010Kx_KFryVqNYWBQ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 520.0116988873679, "y": 363.32095846456355, "strokeColor": "#1971c2", "backgroundColor": "#b2f2bb", "width": 99.626953125, "height": 32.199999999999996, "seed": 1466195445, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Pipeline", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Pipeline", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 314, "versionNonce": 1983028731, "isDeleted": false, "id": "9o-DNP0YdlIGVz1kEm_hW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 407.1590381712276, "y": 410.9252244837219, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1869535061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" }, { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083624, "link": null, "locked": false }, { "type": "rectangle", "version": 156, "versionNonce": 1495247317, "isDeleted": false, "id": "q4TKpiq2KAwPaz19GdhtK", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 490.3194993196821, "y": 473.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 111355061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "ya0JzDo-4oscHIq87TZ_D" }, { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" }, { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 156, "versionNonce": 1469425461, "isDeleted": false, "id": "ya0JzDo-4oscHIq87TZ_D", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 566.0118821321821, "y": 478.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1084671509, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "q4TKpiq2KAwPaz19GdhtK", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 236, "versionNonce": 1535319541, "isDeleted": false, "id": "AOJLQFldoHd2vxVtB2jrS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 491.2218643672577, "y": 519.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 812596085, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "FRby8A9aUiKvHpM5mCdDN" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 231, "versionNonce": 28677973, "isDeleted": false, "id": "FRby8A9aUiKvHpM5mCdDN", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 583.0324112422577, "y": 524.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1849820373, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "AOJLQFldoHd2vxVtB2jrS", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 291, "versionNonce": 571598005, "isDeleted": false, "id": "2WwuMWX7YawqK0i1rDPJo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 489.6426911083554, "y": 567.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1840554549, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "UOwxmKIS0W62CFt_ffEy4" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 289, "versionNonce": 4032021, "isDeleted": false, "id": "UOwxmKIS0W62CFt_ffEy4", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 581.4532379833554, "y": 572.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 330077077, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "2WwuMWX7YawqK0i1rDPJo", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 296, "versionNonce": 1539516059, "isDeleted": false, "id": "9laL3864YWOna6NQlVDqq", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 630.0635849044402, "y": 383.14314287821776, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 294.3024370154917, "height": 36.656016722015465, "seed": 207575285, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -1.000156025347643, "gap": 27.782081605504118 }, "endBinding": { "elementId": "vS2PNUbmeBe3EPxl-dID8", "focus": 0.7761987167055517, "gap": 8.978940924346716 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 294.3024370154917, -36.656016722015465 ] ] }, { "type": "text", "version": 249, "versionNonce": 2076402229, "isDeleted": false, "id": "vS2PNUbmeBe3EPxl-dID8", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 933.3449628442786, "y": 336.02200598023114, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 301.298828125, "height": 46, "seed": 1632793173, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "A pipeline is triggered by an event\nlike a push, tag, manual", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "A pipeline is triggered by an event\nlike a push, tag, manual", "lineHeight": 1.15, "baseline": 41 }, { "type": "arrow", "version": 751, "versionNonce": 1371044827, "isDeleted": false, "id": "FU4jk6Tz6duLaaZE0Z55A", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 751.1619011845514, "y": 440.8355079324799, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 160.46519124360202, "height": 2.2452348338335923, "seed": 1331388341, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -0.6591700594229558, "gap": 3.8807513696519322 }, "endBinding": { "elementId": "wfFvnFZuh0npL9hh0ez7o", "focus": 0.7652411053273549, "gap": 20.75618622779257 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 160.46519124360202, -2.2452348338335923 ] ] }, { "type": "rectangle", "version": 440, "versionNonce": 819540565, "isDeleted": false, "id": "TbejdIYo_qNDw15yLP2IB", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 406.0812257713851, "y": 626.8305540252475, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1553965333, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 466, "versionNonce": 663477, "isDeleted": false, "id": "wfFvnFZuh0npL9hh0ez7o", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 932.383278655946, "y": 424.0107569968011, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 481.2890625, "height": 115, "seed": 781497973, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "lineHeight": 1.15, "baseline": 110 }, { "type": "arrow", "version": 464, "versionNonce": 734626075, "isDeleted": false, "id": "1ZbDRqbETCkEx62nCmnpJ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 741.0645380446722, "y": 492.31283255558515, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 178.4459423531871, "height": 83.08707392565111, "seed": 536879061, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "q4TKpiq2KAwPaz19GdhtK", "focus": -0.7697471991854113, "gap": 3.7450387249900814 }, "endBinding": { "elementId": "Vu0JJ6ZWuEhEyCfxeHPtc", "focus": -0.7822252364700005, "gap": 8.360835317635974 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 178.4459423531871, 83.08707392565111 ] ] }, { "type": "text", "version": 327, "versionNonce": 371646421, "isDeleted": false, "id": "Vu0JJ6ZWuEhEyCfxeHPtc", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 927.8713157154953, "y": 563.2132686484658, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 491.357421875, "height": 46, "seed": 385310005, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "lineHeight": 1.15, "baseline": 41 }, { "type": "text", "version": 91, "versionNonce": 1180085909, "isDeleted": false, "id": "0tGx2VdJLNf7W6HD76dtO", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 427.6895298601876, "y": 432.3583566254258, "strokeColor": "#9c36b5", "backgroundColor": "#a5d8ff", "width": 143.876953125, "height": 23, "seed": 450883221, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"build\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"build\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 338, "versionNonce": 957223925, "isDeleted": false, "id": "LQ2h2aO9uzDWyLG6OLn70", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.7251825950889, "y": 685.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 711939061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "8EqaPnZX2CgLaF08UNZZg" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 340, "versionNonce": 510774613, "isDeleted": false, "id": "8EqaPnZX2CgLaF08UNZZg", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 563.4175654075889, "y": 690.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1370164565, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "LQ2h2aO9uzDWyLG6OLn70", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 421, "versionNonce": 97999541, "isDeleted": false, "id": "St9t4nwHuXXVlmjDqfn_Z", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 488.62754764266447, "y": 731.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 2145950389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "DX10t075MMDu7BLtuUaij" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 417, "versionNonce": 2011446293, "isDeleted": false, "id": "DX10t075MMDu7BLtuUaij", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 580.4380945176645, "y": 736.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 500005909, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "St9t4nwHuXXVlmjDqfn_Z", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 475, "versionNonce": 1284370805, "isDeleted": false, "id": "XVGBz_X5yN6xjWTosVH2n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.04837438376217, "y": 779.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1666134389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "-xogFSFcP-Vv5cuOSFm8T" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 476, "versionNonce": 1092221653, "isDeleted": false, "id": "-xogFSFcP-Vv5cuOSFm8T", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 578.8589212587622, "y": 784.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1840462549, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "XVGBz_X5yN6xjWTosVH2n", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 125, "versionNonce": 1310578741, "isDeleted": false, "id": "N1a9yL7Pts16hUKY9-vhw", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 424.78852030984035, "y": 646.2446482189896, "strokeColor": "#be4bdb", "backgroundColor": "#a5d8ff", "width": 133.857421875, "height": 23, "seed": 361699381, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"test\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"test\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 184, "versionNonce": 2127603131, "isDeleted": false, "id": "O-YmtRLb8uFNqCAz22EoG", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 737.454940151797, "y": 535.9141784615474, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 190.41665096887027, "height": 112.96427727851824, "seed": 80234901, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.8392895251910331, "gap": 2.0300115262207328 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 190.41665096887027, 112.96427727851824 ] ] }, { "type": "arrow", "version": 327, "versionNonce": 780710651, "isDeleted": false, "id": "379hO6Dc5rygB38JgDbVo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 738.8084877231549, "y": 591.3526691276127, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 186.8066399682357, "height": 57.68023784868956, "seed": 211046133, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "2WwuMWX7YawqK0i1rDPJo", "focus": -0.5776522830934517, "gap": 2.1657966147995467 }, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.7269489945238884, "gap": 4.286474955497397 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 186.8066399682357, 57.68023784868956 ] ] }, { "type": "text", "version": 285, "versionNonce": 1165977685, "isDeleted": false, "id": "0TjxOfERekC91N3yciQIq", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 929.901602646888, "y": 632.4760859429873, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 518.076171875, "height": 46, "seed": 997763157, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "O-YmtRLb8uFNqCAz22EoG", "type": "arrow" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "lineHeight": 1.15, "baseline": 41 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/20-workflow-syntax.md ================================================ # Workflow syntax The Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status. :::note An exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run. ::: :::note We support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility. Read more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3) ::: Example steps: ```yaml steps: - name: backend image: golang commands: - go build - go test - name: frontend image: node commands: - npm install - npm run test - npm run build ``` In the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary. The name is optional, if not added the steps will be numerated. Another way to name a step is by using dictionaries: ```yaml steps: backend: image: golang commands: - go build - go test frontend: image: node commands: - npm install - npm run test - npm run build ``` ## Skip Commits Woodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive. ```bash git commit -m "updated README [CI SKIP]" ``` ## Steps Every step of your workflow executes commands inside a specified container.
The defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).
The associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` ### File changes are incremental - Woodpecker clones the source code in the beginning of the workflow - Changes to files are persisted through steps as the same volume is mounted to all steps ```yaml title=".woodpecker.yaml" steps: - name: build image: debian commands: - echo "test content" > myfile - name: a-test-step image: debian commands: - cat myfile ``` ### `image` Woodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers. When using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands. ```diff steps: - name: build + image: golang:1.6 commands: - go build - go test - name: prettier + image: woodpeckerci/plugin-prettier services: - name: database + image: mysql ``` Woodpecker supports any valid Docker image from any Docker registry: ```yaml image: golang image: golang:1.7 image: library/golang:1.7 image: index.docker.io/library/golang image: index.docker.io/library/golang:1.7 ``` Learn more how you can use images from [different registries](./41-registries.md). ### `pull` By default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present. To always pull the latest image when updates are available, use the `pull` option: ```diff steps: - name: build image: golang:latest + pull: true ``` ### `commands` Commands of every step are executed serially as if you would enter them into your local shell. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` There is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script: ```bash #!/bin/sh set -e go build go test ``` The above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed: ```bash docker run --entrypoint=build.sh golang ``` :::note Only build steps can define commands. You cannot use commands with plugins or services. ::: ### `entrypoint` Allows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `["/bin/sh", "-c"]`). If you define [`commands`](#commands), the default entrypoint will be `["/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"]`. You can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`. ### `environment` Woodpecker provides the ability to pass environment variables to individual steps. For more details, check the [environment docs](./50-environment.md). ### `failure` Some of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow. ```diff steps: - name: backend image: golang commands: - go build - go test + failure: ignore ``` ### `when` - Conditional Execution Woodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true. A condition can be a check like: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - event: pull_request + repo: test/test + - event: push + branch: main ``` The `prettier` step is executed if one of these conditions is met: 1. The pipeline is executed from a pull request in the repo `test/test` 2. The pipeline is executed from a push to `main` #### `repo` Example conditional execution by repository: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - repo: test/test ``` #### `branch` :::note Branch conditions are not applied to tags. ::: Example conditional execution by branch: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - branch: main ``` > The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only. Execute a step if the branch is `main` or `develop`: ```yaml when: - branch: [main, develop] ``` Execute a step if the branch starts with `prefix/*`: ```yaml when: - branch: prefix/* ``` The branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples: - `*\\/*` to match patterns with exactly 1 `/` - `*\\/**` to match patters with at least 1 `/` - `*` to match patterns without `/` - `**` to match everything Execute a step using custom include and exclude logic: ```yaml when: - branch: include: [main, release/*] exclude: [release/1.0.0, release/1.1.*] ``` #### `event` The available events are: - `push`: triggered when a commit is pushed to a branch. - `pull_request`: triggered when a pull request is opened or a new commit is pushed to it. - `pull_request_closed`: triggered when a pull request is closed or merged. - `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...). - `tag`: triggered when a tag is pushed. - `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).) - `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.) - `cron`: triggered when a cron job is executed. - `manual`: triggered when a user manually triggers a pipeline. Execute a step if the build event is a `tag`: ```yaml when: - event: tag ``` Execute a step if the pipeline event is a `push` to a specified branch: ```diff when: - event: push + branch: main ``` Execute a step for multiple events: ```yaml when: - event: [push, tag, deployment] ``` #### `cron` This filter **only** applies to cron events and filters based on the name of a cron job. Make sure to have a `event: cron` condition in the `when`-filters as well. ```yaml when: - event: cron cron: sync_* # name of your cron job ``` [Read more about cron](./45-cron.md) #### `ref` The `ref` filter compares the git reference against which the workflow is executed. This allows you to filter, for example, tags that must start with **v**: ```yaml when: - event: tag ref: refs/tags/v* ``` #### `status` There are use cases for executing steps on failure, such as sending notifications for failed workflow/pipeline. Use the status constraint to execute steps even when the workflow fails: ```diff steps: - name: notify image: alpine + when: + - status: [ success, failure ] ``` #### `platform` :::note This condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch. ::: Execute a step for a specific platform: ```yaml when: - platform: linux/amd64 ``` Execute a step for a specific platform using wildcards: ```yaml when: - platform: [linux/*, windows/amd64] ``` #### `matrix` Execute a step for a single matrix permutation: ```yaml when: - matrix: GO_VERSION: 1.5 REDIS_VERSION: 2.8 ``` #### `instance` Execute a step only on a certain Woodpecker instance matching the specified hostname: ```yaml when: - instance: stage.woodpecker.company.com ``` #### `path` :::info Path conditions are applied only to **push** and **pull_request** events. ::: Execute a step only on a pipeline with certain files being changed: ```yaml when: - path: 'src/*' ``` You can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`. For pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases. ```yaml when: - path: include: ['.woodpecker/*.yaml', '*.ini'] exclude: ['*.md', 'docs/**'] ignore_message: '[ALL]' on_empty: true ``` :::info Passing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting. ::: #### `evaluate` Execute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression. The expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library. Run on pushes to the default branch for the repository `owner/repo`: ```yaml when: - evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' ``` Run on commits created by user `woodpecker-ci`: ```yaml when: - evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' ``` Skip all commits containing `please ignore me` in the commit message: ```yaml when: - evaluate: 'not (CI_COMMIT_MESSAGE contains "please ignore me")' ``` Run on pull requests with the label `deploy`: ```yaml when: - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "deploy"' ``` Skip step only if `SKIP=true`, run otherwise or if undefined: ```yaml when: - evaluate: 'SKIP != "true"' ``` ### `depends_on` Normally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`: ```diff steps: - name: build # build will be executed immediately image: golang commands: - go build - name: deploy image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file + depends_on: [build, test] # deploy will be executed after build and test finished - name: test # test will be executed immediately as no dependencies are set image: golang commands: - go test ``` :::note You can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified. ```yaml steps: - name: check code format image: mstruebing/editorconfig-checker depends_on: [] # enable parallel steps ... ``` ::: ### `volumes` Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. For more details check the [volumes docs](./70-volumes.md). ### `detach` Woodpecker gives the ability to detach steps to run them in background until the workflow finishes. For more details check the [service docs](./60-services.md#detachment). ### `directory` Using `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run. ### `backend_options` With `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes. Further details can be found in the documentation of the used backend: - [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration) - [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration) ## `services` Woodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow. For more details check the [services docs](./60-services.md). ## `workspace` The workspace defines the shared volume and working directory shared by all workflow steps. The default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`). So an example would be `/woodpecker/src/github.com/octocat/hello-world`. The workspace can be customized using the workspace block in the YAML file: ```diff +workspace: + base: /go + path: src/github.com/octocat/hello-world steps: - name: build image: golang:latest commands: - go get - go test ``` :::note Plugins will always have the workspace base at `/woodpecker` ::: The base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps. ```diff workspace: + base: /go path: src/github.com/octocat/hello-world steps: - name: deps image: golang:latest commands: - go get - go test - name: build image: node:latest commands: - go build ``` This would be equivalent to the following docker commands: ```bash docker volume create my-named-volume docker run --volume=my-named-volume:/go golang:latest docker run --volume=my-named-volume:/go node:latest ``` The path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path. ```diff workspace: base: /go + path: src/github.com/octocat/hello-world ``` ```bash git clone https://github.com/octocat/hello-world \ /go/src/github.com/octocat/hello-world ``` ## `matrix` Woodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations. For more details check the [matrix build docs](./30-matrix-workflows.md). ## `labels` You can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent. To specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo. Workflow labels with an empty value are ignored. By default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`. :::warning Labels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition. ::: You can add additional labels as a key value map: ```diff +labels: + location: europe # only agents with `location=europe` or `location=*` will be used + weather: sun + hostname: "" # this label will be ignored as it is empty steps: - name: build image: golang commands: - go build - go test ``` ### Filter by platform To configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key. Have a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`. Example: Assuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`. ```diff +labels: + platform: linux/arm64 steps: [...] ``` ## `variables` Woodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration. For more details and examples check the [Advanced usage docs](./90-advanced-usage.md) ## `clone` Woodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step. You can manually configure the clone step in your workflow to customize it: ```diff +clone: + git: + image: woodpeckerci/plugin-git steps: - name: build image: golang commands: - go build - go test ``` Example configuration to override the depth: ```diff clone: - name: git image: woodpeckerci/plugin-git + settings: + partial: false + depth: 50 ``` Example configuration to use a custom clone plugin: ```diff clone: - name: git + image: octocat/custom-git-plugin ``` ### Git Submodules To use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`: ```diff [submodule "my-module"] path = my-module -url = git@github.com:octocat/my-module.git +url = https://github.com/octocat/my-module.git ``` To use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`: ```diff clone: - name: git image: woodpeckerci/plugin-git settings: recursive: true + submodule_override: + my-module: https://github.com/octocat/my-module.git steps: ... ``` ## `skip_clone` :::warning The default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`. ::: By default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using: ```yaml skip_clone: true ``` ## `when` - Global workflow conditions Woodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue. For more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution). Example conditional execution by branch: ```diff +when: + branch: main + steps: - name: prettier image: woodpeckerci/plugin-prettier ``` The workflow now triggers on `main`, but also if the target branch of a pull request is `main`. ## `depends_on` Woodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword. ## `runs_on` Workflows that should run even on failure should set the `runs_on` tag. See [here](./25-workflows.md#flow-control) for an example. ## Advanced network options for steps :::warning Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. ::: ### `dns` If the backend engine understands to change the DNS server and lookup domain, this options will be used to alter the default DNS config to a custom one for a specific step. ```yaml steps: - name: build image: plugin/abc dns: 1.2.3.4 dns_search: 'internal.company' ``` ## Privileged mode Woodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities. :::info Privileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker environment: - DOCKER_HOST=tcp://docker:2375 commands: - docker --tls=false ps services: - name: docker image: docker:dind commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false + privileged: true ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/25-workflows.md ================================================ # Workflows A pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps. In case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow. By placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored. You can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md). ## Benefits of using workflows - faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote - better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying - utilizing more agents to speed up the execution of the whole pipeline ## Example workflow definition :::warning Please note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow. If you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket). ::: ```bash .woodpecker/ ├── build.yaml ├── deploy.yaml ├── lint.yaml └── test.yaml ``` ```yaml title=".woodpecker/build.yaml" steps: - name: build image: debian:stable-slim commands: - echo building - sleep 5 ``` ```yaml title=".woodpecker/deploy.yaml" steps: - name: deploy image: debian:stable-slim commands: - echo deploying depends_on: - lint - build - test ``` ```yaml title=".woodpecker/test.yaml" steps: - name: test image: debian:stable-slim commands: - echo testing - sleep 5 depends_on: - build ``` ```yaml title=".woodpecker/lint.yaml" steps: - name: lint image: debian:stable-slim commands: - echo linting - sleep 5 ``` ## Status lines Each workflow will report its own status back to your forge. ## Flow control The workflows run in parallel on separate agents and share nothing. Dependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully. The name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`. ```diff steps: - name: deploy image: debian:stable-slim commands: - echo deploying +depends_on: + - lint + - build + - test ``` Workflows that need to run even on failures should set the `runs_on` tag. ```diff steps: - name: notify image: debian:stable-slim commands: - echo notifying depends_on: - deploy +runs_on: [ success, failure ] ``` :::info Some workflows don't need the source code, like creating a notification on failure. Read more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone) ::: ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/30-matrix-workflows.md ================================================ # Matrix workflows Woodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations. :::warning Woodpecker currently supports a maximum of **27 matrix axes** per workflow. If your matrix exceeds this number, any additional axes will be silently ignored. ::: Example matrix definition: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 REDIS_VERSION: - 2.6 - 2.8 - 3.0 ``` Example matrix definition containing only specific combinations: ```yaml matrix: include: - GO_VERSION: 1.4 REDIS_VERSION: 2.8 - GO_VERSION: 1.5 REDIS_VERSION: 2.8 - GO_VERSION: 1.6 REDIS_VERSION: 3.0 ``` ## Interpolation Matrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 DATABASE: - mysql:8 - mysql:5 - mariadb:10.1 steps: - name: build image: golang:${GO_VERSION} commands: - go get - go build - go test services: - name: database image: ${DATABASE} ``` Example YAML file after injecting the matrix parameters: ```diff steps: - name: build - image: golang:${GO_VERSION} + image: golang:1.4 commands: - go get - go build - go test + environment: + - GO_VERSION=1.4 + - DATABASE=mysql:8 services: - name: database - image: ${DATABASE} + image: mysql:8 ``` ## Examples ### Example matrix pipeline based on Docker image tag ```yaml matrix: TAG: - 1.7 - 1.8 - latest steps: - name: build image: golang:${TAG} commands: - go build - go test ``` ### Example matrix pipeline based on container image ```yaml matrix: IMAGE: - golang:1.7 - golang:1.8 - golang:latest steps: - name: build image: ${IMAGE} commands: - go build - go test ``` ### Example matrix pipeline using multiple platforms ```yaml matrix: platform: - linux/amd64 - linux/arm64 labels: platform: ${platform} steps: - name: test image: alpine commands: - echo "I am running on ${platform}" - name: test-arm-only image: alpine commands: - echo "I am running on ${platform}" - echo "Arm is cool!" when: platform: linux/arm* ``` :::note If you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector). ::: ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/40-secrets.md ================================================ # Secrets Woodpecker provides the ability to store named variables in a central secret store. These secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`. There are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins): 1. **Repository secrets**: Available for all pipelines of a repository. 1. **Organization secrets**: Available for all pipelines of an organization. 1. **Global secrets**: Can only be set by instance administrators. Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution. In addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources. :::warning Woodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs. ::: ## Usage You can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax. The following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`: ```diff steps: - name: 'step name' image: registry/repo/image:tag commands: + - echo "The secret is $TOKEN_ENV" + environment: + TOKEN_ENV: + from_secret: secret_token ``` The same syntax can be used to pass secrets to (plugin) settings. A secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details). `PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution. ```diff steps: - name: 'step name' image: registry/repo/image:tag + settings: + TOKEN: + from_secret: secret_token ``` ### Escape secrets Please note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts. If secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing. ```diff steps: - name: docker image: docker commands: - - echo ${TOKEN_ENV} + - echo $${TOKEN_ENV} environment: TOKEN_ENV: from_secret: secret_token ``` ### Events filter By default, secrets are not exposed to pull requests. However, you can change this behavior by creating the secret and enabling the `pull_request` event type. This can be configured either via the UI or via the CLI. :::warning Be careful when exposing secrets for pull requests. If your repository is public and accepts pull requests from everyone, your secrets may be at risk. Malicious actors could take advantage of this to expose your secrets or transfer them to an external location. ::: ### Plugins filter To prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins. If enabled, they are not available to any other plugins. Plugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets. :::tip If you specify a tag, the filter will take it into account. However, if the same image appears several times in the list, the least privileged entry will take precedence. For example, an image without a tag will allow all tags, even if it contains another entry with a tag attached. ::: ![plugins filter](./secrets-plugins-filter.png) ## CLI In addition to the UI, secrets can also be managed using the CLI. Create the secret with the default settings. The secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events). ```bash woodpecker-cli repo secret add \ --repository octocat/hello-world \ --name aws_access_key_id \ --value ``` Create the secret and limit it to a single image: ```diff woodpecker-cli secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ --name aws_access_key_id \ --value ``` Create the secrets and limit it to a set of images: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ + --image woodpeckerci/plugin-docker-buildx \ --name aws_access_key_id \ --value ``` Create the secret and enable it for multiple hook events: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ --image woodpeckerci/plugin-s3 \ + --event pull_request \ + --event push \ + --event tag \ --name aws_access_key_id \ --value ``` Secrets can be loaded from a file using the syntax `@`. This method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example): ```diff woodpecker-cli repo secret add \ -repository octocat/hello-world \ -name ssh_key \ + -value @/root/ssh/id_rsa ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/41-registries.md ================================================ # Registries Woodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries. ## Images from private registries You must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file. These credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin. Example configuration using a private image: ```diff steps: - name: build + image: gcr.io/custom/golang commands: - go build - go test ``` Woodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers. Example registry hostnames: - Image `gcr.io/foo/bar` has hostname `gcr.io` - Image `foo/bar` has hostname `docker.io` - Image `qux.com:8000/foo/bar` has hostname `qux.com:8000` Example registry hostname matching logic: - Hostname `gcr.io` matches image `gcr.io/foo/bar` - Hostname `docker.io` matches `golang` - Hostname `docker.io` matches `library/golang` - Hostname `docker.io` matches `bradrydzewski/golang` - Hostname `docker.io` matches `bradrydzewski/golang:latest` ## Global registry support To make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config). ## GCR registry support For specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file). ## Local Images :::warning For this, privileged rights are needed only available to admins. In addition, this only works when using a single agent. ::: It's possible to build a local image by mounting the docker socket as a volume. With a `Dockerfile` at the root of the project: ```yaml steps: - name: build-image image: docker commands: - docker build --rm -t local/project-image . volumes: - /var/run/docker.sock:/var/run/docker.sock - name: build-project image: local/project-image commands: - ./build.sh ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/45-cron.md ================================================ # Cron To configure cron jobs you need at least push access to the repository. ## Add a new cron job 1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job: ```diff steps: - name: sync_locales image: weblate_sync settings: url: example.com token: from_secret: weblate_token + when: + event: cron + cron: "name of the cron job" # if you only want to execute this step by a specific cron job ``` 2. Create a new cron job in the repository settings: ![cron settings](./cron-settings.png) The supported schedule syntax can be found at . If you need general understanding of the cron syntax is a good place to start and experiment. Examples: `@every 5m`, `@daily`, `30 * * * *` ... ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/50-environment.md ================================================ # Environment variables Woodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables: ```diff steps: - name: build image: golang + environment: + CGO: 0 + GOOS: linux + GOARCH: amd64 commands: - go build - go test ``` Please note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section. ```diff steps: - name: build image: golang - environment: - - PATH=$PATH:/go commands: + - export PATH=$PATH:/go - go build - go test ``` :::warning `${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped: ::: ```diff steps: - name: build image: golang commands: - - export PATH=${PATH}:/go + - export PATH=$${PATH}:/go - go build - go test ``` ## Built-in environment variables This is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime. | NAME | Description | Example | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `CI` | CI environment name | `woodpecker` | | | **Repository** | | | `CI_REPO` | repository full name `/` | `john-doe/my-repo` | | `CI_REPO_OWNER` | repository owner | `john-doe` | | `CI_REPO_NAME` | repository name | `my-repo` | | `CI_REPO_REMOTE_ID` | repository remote ID, is the UID it has in the forge | `82` | | `CI_REPO_URL` | repository web URL | `https://git.example.com/john-doe/my-repo` | | `CI_REPO_CLONE_URL` | repository clone URL | `https://git.example.com/john-doe/my-repo.git` | | `CI_REPO_CLONE_SSH_URL` | repository SSH clone URL | `git@git.example.com:john-doe/my-repo.git` | | `CI_REPO_DEFAULT_BRANCH` | repository default branch | `main` | | `CI_REPO_PRIVATE` | repository is private | `true` | | `CI_REPO_TRUSTED_NETWORK` | repository has trusted network access | `false` | | `CI_REPO_TRUSTED_VOLUMES` | repository has trusted volumes access | `false` | | `CI_REPO_TRUSTED_SECURITY` | repository has trusted security access | `false` | | | **Current Commit** | | | `CI_COMMIT_SHA` | commit SHA | `eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_COMMIT_REF` | commit ref | `refs/heads/main` | | `CI_COMMIT_REFSPEC` | commit ref spec | `issue-branch:main` | | `CI_COMMIT_BRANCH` | commit branch (equals target branch for pull requests) | `main` | | `CI_COMMIT_SOURCE_BRANCH` | commit source branch (set only for pull request events) | `issue-branch` | | `CI_COMMIT_TARGET_BRANCH` | commit target branch (set only for pull request events) | `main` | | `CI_COMMIT_TAG` | commit tag name (empty if event is not `tag`) | `v1.10.3` | | `CI_COMMIT_PULL_REQUEST` | commit pull request number (set only for pull request events) | `1` | | `CI_COMMIT_PULL_REQUEST_LABELS` | labels assigned to pull request (set only for pull request events) | `server` | | `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events) | `summer-sprint` | | `CI_COMMIT_MESSAGE` | commit message | `Initial commit` | | `CI_COMMIT_AUTHOR` | commit author username | `john-doe` | | `CI_COMMIT_AUTHOR_EMAIL` | commit author email address | `john-doe@example.com` | | `CI_COMMIT_PRERELEASE` | release is a pre-release (empty if event is not `release`) | `false` | | | **Current pipeline** | | | `CI_PIPELINE_NUMBER` | pipeline number | `8` | | `CI_PIPELINE_PARENT` | number of parent pipeline | `0` | | `CI_PIPELINE_EVENT` | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PIPELINE_EVENT_REASON` | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PIPELINE_URL` | link to the web UI for the pipeline | `https://ci.example.com/repos/7/pipeline/8` | | `CI_PIPELINE_FORGE_URL` | link to the forge's web UI for the commit(s) or tag that triggered the pipeline | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_PIPELINE_DEPLOY_TARGET` | pipeline deploy target for `deployment` events | `production` | | `CI_PIPELINE_DEPLOY_TASK` | pipeline deploy task for `deployment` events | `migration` | | `CI_PIPELINE_CREATED` | pipeline created UNIX timestamp | `1722617519` | | `CI_PIPELINE_STARTED` | pipeline started UNIX timestamp | `1722617519` | | `CI_PIPELINE_FILES` | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[".woodpecker.yml","README.md"]` | | `CI_PIPELINE_AUTHOR` | pipeline author username | `octocat` | | `CI_PIPELINE_AVATAR` | pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | | **Current workflow** | | | `CI_WORKFLOW_NAME` | workflow name | `release` | | | **Current step** | | | `CI_STEP_NAME` | step name | `build package` | | `CI_STEP_NUMBER` | step number | `0` | | `CI_STEP_STARTED` | step started UNIX timestamp | `1722617519` | | `CI_STEP_URL` | URL to step in UI | `https://ci.example.com/repos/7/pipeline/8` | | | **Previous commit** | | | `CI_PREV_COMMIT_SHA` | previous commit SHA | `15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_REF` | previous commit ref | `refs/heads/main` | | `CI_PREV_COMMIT_REFSPEC` | previous commit ref spec | `issue-branch:main` | | `CI_PREV_COMMIT_BRANCH` | previous commit branch | `main` | | `CI_PREV_COMMIT_SOURCE_BRANCH` | previous commit source branch (set only for pull request events) | `issue-branch` | | `CI_PREV_COMMIT_TARGET_BRANCH` | previous commit target branch (set only for pull request events) | `main` | | `CI_PREV_COMMIT_URL` | previous commit link in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_MESSAGE` | previous commit message | `test` | | `CI_PREV_COMMIT_AUTHOR` | previous commit author username | `john-doe` | | `CI_PREV_COMMIT_AUTHOR_EMAIL` | previous commit author email address | `john-doe@example.com` | | | **Previous pipeline** | | | `CI_PREV_PIPELINE_NUMBER` | previous pipeline number | `7` | | `CI_PREV_PIPELINE_PARENT` | previous pipeline number of parent pipeline | `0` | | `CI_PREV_PIPELINE_EVENT` | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PREV_PIPELINE_EVENT_REASON` | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PREV_PIPELINE_URL` | previous pipeline link in CI | `https://ci.example.com/repos/7/pipeline/7` | | `CI_PREV_PIPELINE_FORGE_URL` | previous pipeline link to event in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_PIPELINE_DEPLOY_TARGET` | previous pipeline deploy target for `deployment` events | `production` | | `CI_PREV_PIPELINE_DEPLOY_TASK` | previous pipeline deploy task for `deployment` events | `migration` | | `CI_PREV_PIPELINE_STATUS` | previous pipeline status | `success`, `failure` | | `CI_PREV_PIPELINE_CREATED` | previous pipeline created UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_STARTED` | previous pipeline started UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_FINISHED` | previous pipeline finished UNIX timestamp | `1722610383` | | `CI_PREV_PIPELINE_AUTHOR` | previous pipeline author username | `octocat` | | `CI_PREV_PIPELINE_AVATAR` | previous pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | |   | | | `CI_WORKSPACE` | Path of the workspace where source code gets cloned to | `/woodpecker/src/git.example.com/john-doe/my-repo` | | | **System** | | | `CI_SYSTEM_NAME` | name of the CI system | `woodpecker` | | `CI_SYSTEM_URL` | link to CI system | `https://ci.example.com` | | `CI_SYSTEM_HOST` | hostname of CI server | `ci.example.com` | | `CI_SYSTEM_VERSION` | version of the server | `2.7.0` | | | **Forge** | | | `CI_FORGE_TYPE` | name of forge | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab` | | `CI_FORGE_URL` | root URL of configured forge | `https://git.example.com` | | | **Internal** - Please don't use! | | | `CI_SCRIPT` | Internal script path. Used to call pipeline step commands. | | | `CI_NETRC_USERNAME` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_PASSWORD` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_MACHINE` | Credentials for private repos to be able to clone data. (Only available for specific images) | | ## Global environment variables If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` These can be used, for example, to manage the image tag used by multiple projects. ```ini WOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18 ``` ```diff steps: - name: build - image: golang:1.18 + image: golang:${GOLANG_VERSION} commands: - [...] ``` ## String Substitution Woodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration. Example commit substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA} ``` Example tag substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG} ``` ## String Operations Woodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values. | OPERATION | DESCRIPTION | | ------------------ | ------------------------------------------------ | | `${param}` | parameter substitution | | `${param,}` | parameter substitution with lowercase first char | | `${param,,}` | parameter substitution with lowercase | | `${param^}` | parameter substitution with uppercase first char | | `${param^^}` | parameter substitution with uppercase | | `${param:pos}` | parameter substitution with substring | | `${param:pos:len}` | parameter substitution with substring and length | | `${param=default}` | parameter substitution with default | | `${param##prefix}` | parameter substitution with prefix removal | | `${param%%suffix}` | parameter substitution with suffix removal | | `${param/old/new}` | parameter substitution with find and replace | Example variable substitution with substring: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA:0:8} ``` Example variable substitution strips `v` prefix from `v.1.0.0`: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG##v} ``` ## `pull_request_metadata` specific event reason values For the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`. **GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list. :::note Event reason values are forge-specific and may change between versions. ::: | Event | GitHub | Gitea | Forgejo | GitLab | Bitbucket | Bitbucket Datacenter | Description | | -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ | | `assigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was assigned to a user | | `converted_to_draft` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Pull request was converted to a draft | | `demilestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was removed from a milestone | | `description_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Description edited | | `edited` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | The title or body of a pull request was edited, or the base branch was changed | | `label_added` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Pull had no labels and now got label(s) added | | `label_cleared` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | All labels removed | | `label_updated` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | New label(s) added / label(s) changed | | `locked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was locked | | `milestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was added to a milestone | | `ready_for_review` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Draft pull request was marked as ready for review | | `review_requested` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | New review was requested | | `title_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Title edited | | `unassigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | User was unassigned from a pull request | | `unlabeled` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Label was removed from a pull request | | `unlocked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was unlocked | **Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214). ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/51-plugins/20-creating-plugins.md ================================================ # Creating plugins Creating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT. ## Settings To allow users to configure the behavior of your plugin, you should use `settings:`. These are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix. Using a setting like `url` results in an env var named `PLUGIN_URL`. Characters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`. CamelCase is not respected, `anInt` get `PLUGIN_ANINT`. ### Basic settings Using any basic YAML type (scalar) will be converted into a string: | Setting | Environment value | | -------------------- | ---------------------------- | | `some-bool: false` | `PLUGIN_SOME_BOOL="false"` | | `some_String: hello` | `PLUGIN_SOME_STRING="hello"` | | `anInt: 3` | `PLUGIN_ANINT="3"` | ### Complex settings It's also possible to use complex settings like this: ```yaml steps: - name: plugin image: foo/plugin settings: complex: abc: 2 list: - 2 - 3 ``` Values like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{"abc": "2", "list": [ "2", "3" ]}`. ### Secrets Secrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage). ## Plugin library For Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See . ## Metadata In your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins). Supported metadata: - `name`: The plugin's full name - `icon`: URL to your plugin's icon - `description`: A short description of what it's doing - `author`: Your name - `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin) - `containerImage`: name of the container image - `containerImageUrl`: link to the container image - `url`: homepage or repository of your plugin If you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required. ## Example plugin This provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline. ### What end users will see The below example demonstrates how we might configure a webhook plugin in the YAML file: ```yaml steps: - name: webhook image: foo/webhook settings: url: https://example.com method: post body: | hello world ``` ### Write the logic Create a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`. ```bash #!/bin/sh curl \ -X ${PLUGIN_METHOD} \ -d ${PLUGIN_BODY} \ ${PLUGIN_URL} ``` ### Package it Create a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint. ```dockerfile # please pin the version, e.g. alpine:3.19 FROM alpine ADD script.sh /bin/ RUN chmod +x /bin/script.sh RUN apk -Uuv add curl ca-certificates ENTRYPOINT /bin/script.sh ``` Build and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community. ```shell docker build -t foo/webhook . docker push foo/webhook ``` Execute your plugin locally from the command line to verify it is working: ```shell docker run --rm \ -e PLUGIN_METHOD=post \ -e PLUGIN_URL=https://example.com \ -e PLUGIN_BODY="hello world" \ foo/webhook ``` ## Best practices - Build your plugin for different architectures to allow many users to use them. At least, you should support `amd64` and `arm64`. - Provide binaries for users using the `local` backend. These should also be built for different OS/architectures. - Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible. - Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names. - Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)). - Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)). ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/51-plugins/51-overview.md ================================================ # Plugins Plugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline. Plugins can be used to deploy code, publish artifacts, send notification, and more. They are automatically pulled from the default container registry the agent's have configured. ```dockerfile title="Dockerfile" FROM cloud/kubectl COPY deploy /usr/local/deploy ENTRYPOINT ["/usr/local/deploy"] ``` ```bash title="deploy" kubectl apply -f $PLUGIN_TEMPLATE ``` ```yaml title=".woodpecker.yaml" steps: - name: deploy-to-k8s image: cloud/my-k8s-plugin settings: template: config/k8s/service.yaml ``` Example pipeline using the Prettier and S3 plugins: ```yaml steps: - name: build image: golang commands: - go build - go test - name: prettier image: woodpeckerci/plugin-prettier - name: publish image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file ``` ## Plugin Isolation Plugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree. While normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author. That's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically adjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands` or `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin anymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition. ## Finding Plugins For official plugins, you can use the Woodpecker plugin index: - [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins) :::tip There are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking. - [Drone Plugins](http://plugins.drone.io) - [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/) - [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community) ::: ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/51-plugins/_category_.yaml ================================================ label: 'Plugins' # position: 2 collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/60-services.md ================================================ # Services Woodpecker provides a services section in the YAML file used for defining service containers. The below configuration composes database and cache containers. Services are accessed using custom hostnames. In the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`. ```yaml steps: - name: build image: golang commands: - go build - go test services: - name: database image: mysql - name: cache image: redis ``` You can define a port and a protocol explicitly: ```yaml services: - name: database image: mysql ports: - 3306 - name: wireguard image: wg ports: - 51820/udp ``` ## Configuration Service containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more. ```diff services: - name: database image: mysql + environment: + MYSQL_DATABASE: test + MYSQL_ALLOW_EMPTY_PASSWORD: yes - name: cache image: redis ``` ## Detachment Service and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required. ```diff steps: - name: build image: golang commands: - go build - go test - name: database image: redis + detach: true - name: test image: golang commands: - go test ``` Containers from detached steps will terminate when the pipeline ends. ## Initialization Service containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff. ```diff steps: - name: test image: golang commands: + - sleep 15 - go get - go test services: - name: database image: mysql ``` ## Complete Pipeline Example ```yaml services: - name: database image: mysql environment: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: example steps: - name: get-version image: ubuntu commands: - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null - sleep 30s # need to wait for mysql-server init - echo 'SHOW VARIABLES LIKE "version"' | mysql -u root -h database test -p example ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/70-volumes.md ================================================ # Volumes Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. :::note Volumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker commands: - docker build --rm -t octocat/hello-world . - docker run --rm octocat/hello-world --test - docker push octocat/hello-world - docker rmi octocat/hello-world volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` If you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`. Please note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error. ```diff -volumes: [ ./certs:/etc/ssl/certs ] +volumes: [ /etc/ssl/certs:/etc/ssl/certs ] ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/72-extensions/40-configuration-extension.md ================================================ # Configuration extension The configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Preprocess the original configuration file with something like Go templating - Convert custom attributes to Woodpecker attributes - Add defaults to the configuration like default steps - Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ... - Centralize configuration for multiple repositories in one place ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your configuration extension. The global configuration will be called before the repository specific configuration extension if both are configured. ```ini title="Server" WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig ``` ## How it works When a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used. ### Request The extension receives an HTTP POST request with the following JSON payload: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc: Netrc; configuration: { name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "configs": [ { "name": ".woodpecker.yaml", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from Repo (.woodpecker.yaml)\"\n" } ] } ``` ### Response The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. ```ts class Response { configs: { name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Example response: ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/72-extensions/_category_.yaml ================================================ label: 'Extensions' # position: 3 collapsible: true collapsed: true link: type: 'doc' id: 'index' ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/72-extensions/index.md ================================================ # Extensions Woodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints. There is currently one type of extension available: - [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly. ## Security :::warning You need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful data like malicious pipeline configurations that could be executed. ::: To prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair. To verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign). You can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page. ## Example extensions A simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions) ## Configuration To prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of: - Built-in networks: - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included. - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet. - `external`: A valid non-private unicast IP, you can access all hosts on public internet. - `*`: All hosts are allowed. - CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6 - (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/72-linter.md ================================================ # Linter Woodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines. ![errors and warnings in UI](./linter-warnings-errors.png) ## Running the linter from CLI You can run the linter also manually from the CLI: ```shell woodpecker-cli lint ``` ## Bad habit warnings Woodpecker warns you if your configuration contains some bad habits. ### Event filter for all steps All your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well. Examples of an **incorrect** config for this rule: ```yaml when: - branch: main - event: tag ``` This will trigger the warning because the first item (`branch: main`) does not filter with an event. ```yaml steps: - name: test when: branch: main - name: deploy when: event: tag ``` Examples of a **correct** config for this rule: ```yaml when: - branch: main event: push - event: tag ``` ```yaml steps: - name: test when: event: [tag, push] - name: deploy when: - event: tag ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/75-project-settings.md ================================================ # Project settings As the owner of a project in Woodpecker you can change project related settings via the web interface. ![project settings](./project-settings.png) ## Pipeline path The path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`. ## Repository hooks Your Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting. ## Allow pull requests Enables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests. ## Allow deployments Enables a pipeline to be started with the `deploy` event from a successful pipeline. :::danger Only activate this option if you trust all users who have push access to your repository. Otherwise, these users will be able to steal secrets that are only available for `deploy` events. ::: ## Require approval for To prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`. ## Trusted If you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes. :::note Only server admins can set this option. If you are not a server admin this option won't be shown in your project settings. ::: ## Custom trusted clone plugins During the clone process, Git credentials (e.g., for private repositories) may be required. These credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html). These credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting. With these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo. To prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level. Without an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step. :::info This setting does not affect subsequent steps, nor does it allow direct pushes to the repository. To enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push). ::: ## Project visibility You can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners. - `Public` Every user can see your project without being logged in. - `Internal` Only authenticated users of the Woodpecker instance can see this project. - `Private` Only you and other owners of the repository can see this project. ## Timeout After this timeout a pipeline has to finish or will be treated as timed out. ## Cancel previous pipelines By enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one. ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/80-badges.md ================================================ # Status Badges Woodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code. ## Badge endpoint ```uri :///api/badges//status.svg ``` The status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter. ```diff -:///api/badges//status.svg +:///api/badges//status.svg?branch= ``` Please note status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/90-advanced-usage.md ================================================ # Advanced usage ## Advanced YAML syntax YAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config: ### Anchors & aliases You can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config. To convert this: ```yaml steps: - name: test image: golang:1.18 commands: go test ./... - name: build image: golang:1.18 commands: build ``` Just add a new section called **variables** like this: ```diff +variables: + - &golang_image 'golang:1.18' steps: - name: test - image: golang:1.18 + image: *golang_image commands: go test ./... - name: build - image: golang:1.18 + image: *golang_image commands: build ``` ### Map merges and overwrites ```yaml variables: - &base-plugin-settings target: dist recursive: false try: true - &special-setting special: true - &some-plugin codeberg.org/6543/docker-images/print_env steps: - name: develop image: *some-plugin settings: <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map when: branch: develop - name: main image: *some-plugin settings: <<: *base-plugin-settings # merge one map and ... try: false # ... overwrite original value ongoing: false # ... adding a new value when: branch: main ``` ### Sequence merges ```yaml variables: pre_cmds: &pre_cmds - echo start - whoami post_cmds: &post_cmds - echo stop hello_cmd: &hello_cmd - echo hello steps: - name: step1 image: debian commands: - <<: *pre_cmds # prepend a sequence - echo exec step now do dedicated things - <<: *post_cmds # append a sequence - name: step2 image: debian commands: - <<: [*pre_cmds, *hello_cmd] # prepend two sequences - echo echo from second step - <<: *post_cmds ``` ### References - [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) - [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml) ## Persisting environment data between steps One can create a file containing environment variables, and then source it in each step that needs them. ```yaml steps: - name: init image: bash commands: - echo "FOO=hello" >> envvars - echo "BAR=world" >> envvars - name: debug image: bash commands: - source ./envvars - echo $FOO ``` ## Declaring global variables As described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables: ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` Note that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps. ## Docker in docker (dind) setup :::warning This set up will only work on trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable "trusted" mode. ::: The snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service. :::note If your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead. ::: First we need to define a service running a docker with the `dind` tag. This service must run in `privileged` mode: ```yaml services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true ports: - 2376 ``` Next, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28). This can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below). ```diff services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true + environment: + DOCKER_TLS_CERTDIR: /dind-certs + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` In the docker client step: 1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon. These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them). 2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`) Test the connection with the docker client: ```diff steps: - name: test image: docker:cli # in production use something like 'docker:-cli' + environment: + DOCKER_HOST: "tcp://docker:2376" + DOCKER_CERT_PATH: "/dind-certs/client" + DOCKER_TLS_VERIFY: "1" + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version ``` This step should output the server and client version information if everything has been set up correctly. Full example: ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/dind-certs/client' DOCKER_TLS_VERIFY: '1' volumes: - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true environment: DOCKER_TLS_CERTDIR: /dind-certs volumes: - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` ================================================ FILE: docs/versioned_docs/version-3.12/20-usage/_category_.yaml ================================================ label: 'Usage' # position: 2 collapsible: true collapsed: false ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/00-general.md ================================================ # General Woodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`). The **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files. The **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance. The **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time). :::tip You can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent. ::: ## Database Woodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page. ## Forge What would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page. ## Container images :::info No `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch. ::: - `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image) - `vX.Y` - `vX` - `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0). - `vX.Y-alpine` - `vX-alpine` - `next`: Built from the `main` branch - `pull_`: Images built from Pull Request branches. Images are pushed to DockerHub and Quay. - woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server)) - woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent)) - woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli)) - woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler)) ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/05-installation/10-docker-compose.md ================================================ # Docker Compose This example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings. It creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information. The server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it. ```yaml title="docker-compose.yaml" services: woodpecker-server: image: woodpeckerci/woodpecker-server:v3 ports: - 8000:8000 volumes: - woodpecker-server-data:/var/lib/woodpecker/ environment: - WOODPECKER_OPEN=true - WOODPECKER_HOST=${WOODPECKER_HOST} - WOODPECKER_GITHUB=true - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: image: woodpeckerci/woodpecker-agent:v3 command: agent restart: always depends_on: - woodpecker-server volumes: - woodpecker-agent-config:/etc/woodpecker - /var/run/docker.sock:/var/run/docker.sock environment: - WOODPECKER_SERVER=woodpecker-server:9000 - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} volumes: woodpecker-server-data: woodpecker-agent-config: ``` Woodpecker must know its own address. You must therefore specify the public address in the format `://`. Please omit any trailing slashes: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_HOST=${WOODPECKER_HOST} ``` It is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR} + - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR} ``` If the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: - [...] + - WOODPECKER_GRPC_SECURE=true # defaults to false + - WOODPECKER_GRPC_VERIFY=true # default ``` As agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] + volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` Agents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: + - WOODPECKER_SERVER=woodpecker-server:9000 ``` The server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Handling sensitive data There are several options for handling sensitive data in `docker compose` or `docker swarm` configurations: For Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure. Alternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret + secrets: + - woodpecker-agent-secret + + secrets: + woodpecker-agent-secret: + external: true ``` To store values in a docker secret you can use the following command: ```bash echo "my_agent_secret_key" | docker secret create woodpecker-agent-secret - ``` ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/05-installation/20-helm-chart.md ================================================ # Helm Chart Woodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments: ```bash helm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version ``` ## Metrics To enable metrics gathering, set the following in values.yml: ```yaml metrics: enabled: true port: 9001 ``` This activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics. To enable both Prometheus pod monitoring discovery, set: ```yaml prometheus: podmonitor: enabled: true interval: 60s labels: {} ``` If you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled: ```yaml # Search all available namespaces podMonitorNamespaceSelector: matchLabels: {} # Enable all available pod monitors podMonitorSelector: matchLabels: {} ``` ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/05-installation/30-packages.md ================================================ # Distribution packages ## Official packages - DEB - RPM The pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution. ```Shell RELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '"tag_name":\s"v\K[^"]+') # Debian/Ubuntu (x86_64) curl -fLOOO "https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb" sudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb # CentOS/RHEL (x86_64) sudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm ``` The package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values. ```ini title="/usr/local/lib/systemd/system/woodpecker-server.service" [Unit] Description=WoodpeckerCI server Documentation=https://woodpecker-ci.org/docs/administration/server-config Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env ConditionPathExists=/etc/woodpecker/woodpecker-server.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-server.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-server WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-server.env" WOODPECKER_OPEN=true WOODPECKER_HOST=${WOODPECKER_HOST} WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` After installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server. ```ini title="/usr/local/lib/systemd/system/woodpecker-agent.service" [Unit] Description=WoodpeckerCI agent Documentation=https://woodpecker-ci.org/docs/administration/configuration/agent Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env ConditionPathExists=/etc/woodpecker/woodpecker-agent.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-agent.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-agent WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-agent.env" WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Community packages :::info Woodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions. ::: - [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=) - [Arch Linux](https://archlinux.org/packages/?q=woodpecker) - [openSUSE](https://software.opensuse.org/package/woodpecker) - [YunoHost](https://apps.yunohost.org/app/woodpecker) - [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html) - [Easypanel](https://easypanel.io/docs/templates/woodpeckerci) ### NixOS :::info This module is not maintained by the Woodpecker developers. If you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained. ::: In theory, the NixOS installation is very similar to the binary installation and supports multiple backends. In practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken. ```nix { config , ... }: let domain = "woodpecker.example.org"; in { # This automatically sets up certificates via let's encrypt security.acme.defaults.email = "acme@example.com"; security.acme.acceptTerms = true; # Setting up a nginx proxy that handles tls for us services.nginx = { enable = true; openFirewall = true; recommendedTlsSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; virtualHosts."${domain}" = { enableACME = true; forceSSL = true; locations."/".proxyPass = "http://localhost:3007"; }; }; services.woodpecker-server = { enable = true; environment = { WOODPECKER_HOST = "https://${domain}"; WOODPECKER_SERVER_ADDR = ":3007"; WOODPECKER_OPEN = "true"; }; # You can pass a file with env vars to the system it could look like: # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX environmentFile = "/path/to/my/secrets/file"; }; # This sets up a woodpecker agent services.woodpecker-agents.agents."docker" = { enable = true; # We need this to talk to the podman socket extraGroups = [ "podman" ]; environment = { WOODPECKER_SERVER = "localhost:9000"; WOODPECKER_MAX_WORKFLOWS = "4"; DOCKER_HOST = "unix:///run/podman/podman.sock"; WOODPECKER_BACKEND = "docker"; }; # Same as with woodpecker-server environmentFile = [ "/var/lib/secrets/woodpecker.env" ]; }; # Here we setup podman and enable dns virtualisation.podman = { enable = true; defaultNetwork.settings = { dns_enabled = true; }; }; # This is needed for podman to be able to talk over dns networking.firewall.interfaces."podman0" = { allowedUDPPorts = [ 53 ]; allowedTCPPorts = [ 53 ]; }; } ``` All configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/05-installation/_category_.yaml ================================================ label: 'Installation' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/10-server.md ================================================ --- toc_max_heading_level: 3 --- # Server ## Forge and User configuration Woodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge. You can also restrict the registration: - closed registration and manually managing users with the CLI `woodpecker-cli user` - open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN` ```ini WOODPECKER_OPEN=false WOODPECKER_ADMIN=john.smith,jane_doe ``` - open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS` ```ini WOODPECKER_OPEN=true WOODPECKER_ORGS=dolores,dog-patch ``` Administrators should also be explicitly set in your configuration. ```ini WOODPECKER_ADMIN=john.smith,jane_doe ``` ## Repository configuration Woodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here. ```ini WOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user ``` ## Databases The default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind: - Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`. - Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably. - Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes. - Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice. ### SQLite By default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] + volumes: + - woodpecker-server-data:/var/lib/woodpecker/ ``` ### MySQL/MariaDB The below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples. The minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information. ```ini WOODPECKER_DATABASE_DRIVER=mysql WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true ``` ### PostgreSQL The below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples. Please use Postgres versions equal or higher than **11**. ```ini WOODPECKER_DATABASE_DRIVER=postgres WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable ``` ## TLS Woodpecker supports SSL configuration by mounting certificates into your container. ```ini WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key ``` TLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library. ### Container configuration In addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] ports: + - 80:80 + - 443:443 - 9000:9000 ``` Additionally, the certificate and key must be mounted and referenced: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: + - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt + - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key volumes: + - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt + - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key ``` ## Reverse Proxy ### Apache This guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration: ```apacheconf ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` You must have these Apache modules installed: - `proxy` - `proxy_http` You must configure Apache to set `X-Forwarded-Proto` when using https. ```diff ProxyPreserveHost On +RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` ### Nginx This guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide). Example configuration: ```nginx server { listen 80; server_name woodpecker.example.com; location / { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` You must configure the proxy to set `X-Forwarded` proxy headers: ```diff server { listen 80; server_name woodpecker.example.com; location / { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` ### Caddy This guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration: ```caddy # expose WebUI and API woodpecker.example.com { reverse_proxy woodpecker-server:8000 } # expose gRPC woodpecker-agent.example.com { reverse_proxy h2c://woodpecker-server:9000 } ``` ### Tunnelmole [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool. Start by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation). After the installation, run the following command to start tunnelmole: ```bash tmole 8000 ``` It will start a tunnel and will give a response like this: ```bash ➜ ~ tmole 8000 http://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 https://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 ``` Set `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server. ### Ngrok [Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command: ```bash ngrok http 8000 ``` Set `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server. ### Traefik To install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https. ```yaml services: server: image: woodpeckerci/woodpecker-server:latest environment: - WOODPECKER_OPEN=true - WOODPECKER_ADMIN=your_admin_user # other settings ... networks: - dmz # externally defined network, so that traefik can connect to the server volumes: - woodpecker-server-data:/var/lib/woodpecker/ deploy: labels: - traefik.enable=true # web server - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000 - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker-secure.tls=true - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-secure.service=woodpecker-service - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker.entrypoints=web - traefik.http.routers.woodpecker.service=woodpecker-service - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker # gRPC service - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000 - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc-secure.tls=true - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc.entrypoints=web - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker volumes: woodpecker-server-data: driver: local networks: dmz: external: true ``` ## Metrics ### Endpoint Woodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above. ```yaml global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` ### Authorization An administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` As an alternative, the token can also be read from a file: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token_file: /etc/secrets/woodpecker-monitoring-token static_configs: - targets: ['woodpecker.domain.com'] ``` ### Reference List of Prometheus metrics specific to Woodpecker: ```yaml # HELP woodpecker_pipeline_count Pipeline count. # TYPE woodpecker_pipeline_count counter woodpecker_pipeline_count{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 woodpecker_pipeline_count{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 # HELP woodpecker_pipeline_time Build time. # TYPE woodpecker_pipeline_time gauge woodpecker_pipeline_time{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 116 woodpecker_pipeline_time{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 155 # HELP woodpecker_pipeline_total_count Total number of builds. # TYPE woodpecker_pipeline_total_count gauge woodpecker_pipeline_total_count 1025 # HELP woodpecker_pending_steps Total number of pending pipeline steps. # TYPE woodpecker_pending_steps gauge woodpecker_pending_steps 0 # HELP woodpecker_repo_count Total number of repos. # TYPE woodpecker_repo_count gauge woodpecker_repo_count 9 # HELP woodpecker_running_steps Total number of running pipeline steps. # TYPE woodpecker_running_steps gauge woodpecker_running_steps 0 # HELP woodpecker_user_count Total number of users. # TYPE woodpecker_user_count gauge woodpecker_user_count 1 # HELP woodpecker_waiting_steps Total number of pipeline waiting on deps. # TYPE woodpecker_waiting_steps gauge woodpecker_waiting_steps 0 # HELP woodpecker_worker_count Total number of workers. # TYPE woodpecker_worker_count gauge woodpecker_worker_count 4 ``` ## External Configuration API To provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service. Before the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration. Every request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/rfc9421) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`. A simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service) :::warning You need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks. ::: ### Configuration ```ini title="Server" WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig ``` #### Example request made by Woodpecker ```json { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipe", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-file-name.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "https://example.com", "login": "user", "password": "password" } } ``` #### Example response structure ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ## UI customization Woodpecker supports custom JS and CSS files. These files must be present in the server's filesystem. They can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment. The configuration variables are independent of each other, which means it can be just one file present, or both. ```ini WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js ``` The examples below show how to place a banner message in the top navigation bar of Woodpecker. ```css title="woodpecker.css" .banner-message { position: absolute; width: 280px; height: 40px; margin-left: 240px; margin-top: 5px; padding-top: 5px; font-weight: bold; background: red no-repeat; text-align: center; } ``` ```javascript title="woodpecker.js" // place/copy a minified version of your preferred lightweight JavaScript library here ... !(function () { 'use strict'; function e() {} /*...*/ })(); $().ready(function () { $('.app nav img').first().htmlAfter(""); }); ``` ## Environment variables ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### LOG_FILE - Name: `WOODPECKER_LOG_FILE` - Default: `stderr` Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. --- ### DATABASE_LOG - Name: `WOODPECKER_DATABASE_LOG` - Default: `false` Enable logging in database engine (currently xorm). --- ### DATABASE_LOG_SQL - Name: `WOODPECKER_DATABASE_LOG_SQL` - Default: `false` Enable logging of sql commands. --- ### DATABASE_MAX_CONNECTIONS - Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS` - Default: `100` Max database connections xorm is allowed create. --- ### DATABASE_IDLE_CONNECTIONS - Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS` - Default: `2` Amount of database connections xorm will hold open. --- ### DATABASE_CONNECTION_TIMEOUT - Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT` - Default: `3 Seconds` Time an active database connection is allowed to stay open. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOST - Name: `WOODPECKER_HOST` - Default: none Server fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix. Examples: - `WOODPECKER_HOST=http://woodpecker.example.org` - `WOODPECKER_HOST=http://example.org/woodpecker` - `WOODPECKER_HOST=http://example.org:1234/woodpecker` --- ### SERVER_ADDR - Name: `WOODPECKER_SERVER_ADDR` - Default: `:8000` Configures the HTTP listener port. --- ### SERVER_ADDR_TLS - Name: `WOODPECKER_SERVER_ADDR_TLS` - Default: `:443` Configures the HTTPS listener port when SSL is enabled. --- ### SERVER_CERT - Name: `WOODPECKER_SERVER_CERT` - Default: none Path to an SSL certificate used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_CERT=/path/to/cert.pem` --- ### SERVER_KEY - Name: `WOODPECKER_SERVER_KEY` - Default: none Path to an SSL certificate key used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_KEY=/path/to/key.pem` --- ### CUSTOM_CSS_FILE - Name: `WOODPECKER_CUSTOM_CSS_FILE` - Default: none File path for the server to serve a custom .CSS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css` --- ### CUSTOM_JS_FILE - Name: `WOODPECKER_CUSTOM_JS_FILE` - Default: none File path for the server to serve a custom .JS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js` --- ### GRPC_ADDR - Name: `WOODPECKER_GRPC_ADDR` - Default: `:9000` Configures the gRPC listener port. --- ### GRPC_SECRET - Name: `WOODPECKER_GRPC_SECRET` - Default: `secret` Configures the gRPC JWT secret. --- ### GRPC_SECRET_FILE - Name: `WOODPECKER_GRPC_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GRPC_SECRET` from the specified filepath. --- ### METRICS_SERVER_ADDR - Name: `WOODPECKER_METRICS_SERVER_ADDR` - Default: none Configures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely. Example: `:9001` --- ### ADMIN - Name: `WOODPECKER_ADMIN` - Default: none Comma-separated list of admin accounts. Example: `WOODPECKER_ADMIN=user1,user2` --- ### ORGS - Name: `WOODPECKER_ORGS` - Default: none Comma-separated list of approved organizations. Example: `org1,org2` --- ### REPO_OWNERS - Name: `WOODPECKER_REPO_OWNERS` - Default: none Repositories by those owners will be allowed to be used in woodpecker. Example: `user1,user2` --- ### OPEN - Name: `WOODPECKER_OPEN` - Default: `false` Enable to allow user registration. --- ### AUTHENTICATE_PUBLIC_REPOS - Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS` - Default: `false` Always use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies. --- ### DEFAULT_ALLOW_PULL_REQUESTS - Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS` - Default: `true` The default setting for allowing pull requests on a repo. --- ### DEFAULT_APPROVAL_MODE - Name: `WOODPECKER_DEFAULT_APPROVAL_MODE` - Default: `forks` The default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`. --- ### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS - Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS` - Default: `pull_request, push` List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created. --- ### DEFAULT_CLONE_PLUGIN - Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN` - Default: `docker.io/woodpeckerci/plugin-git` The default docker image to be used when cloning the repo. It is also added to the trusted clone plugin list. ### DEFAULT_WORKFLOW_LABELS - Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS` - Default: none You can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set. Example: `platform=linux/amd64,backend=docker` ### DEFAULT_PIPELINE_TIMEOUT - Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT` - Default: 60 The default time for a repo in minutes before a pipeline gets killed ### MAX_PIPELINE_TIMEOUT - Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT` - Default: 120 The maximum time in minutes you can set in the repo settings before a pipeline gets killed --- ### SESSION_EXPIRES - Name: `WOODPECKER_SESSION_EXPIRES` - Default: `72h` Configures the session expiration time. Context: when someone does log into Woodpecker, a temporary session token is created. As long as the session is valid (until it expires or log-out), a user can log into Woodpecker, without re-authentication. ### PLUGINS_PRIVILEGED - Name: `WOODPECKER_PLUGINS_PRIVILEGED` - Default: none Docker images to run in privileged mode. Only change if you are sure what you do! You should specify the tag of your images too, as this enforces exact matches. ### PLUGINS_TRUSTED_CLONE - Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE` - Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git` Plugins which are trusted to handle the Git credential info in clone steps. If a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos. You should specify the tag of your images too, as this enforces exact matches. --- ### DOCKER_CONFIG - Name: `WOODPECKER_DOCKER_CONFIG` - Default: none Configures a specific private registry config for all pipelines. Example: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json` --- ### ENVIRONMENT - Name: `WOODPECKER_ENVIRONMENT` - Default: none If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. Example: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2` --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath --- ### DISABLE_USER_AGENT_REGISTRATION - Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION` - Default: false By default, users can create new agents for their repos they have admin access to. If an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements. :::note You should set this option if you have, for example, global secrets and don't trust your users to create a rogue agent and pipeline for secret extraction. ::: --- ### KEEPALIVE_MIN_TIME - Name: `WOODPECKER_KEEPALIVE_MIN_TIME` - Default: none Server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping. Example: `WOODPECKER_KEEPALIVE_MIN_TIME=10s` --- ### DATABASE_DRIVER - Name: `WOODPECKER_DATABASE_DRIVER` - Default: `sqlite3` The database driver name. Possible values are `sqlite3`, `mysql` or `postgres`. --- ### DATABASE_DATASOURCE - Name: `WOODPECKER_DATABASE_DATASOURCE` - Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container The database connection string. The default value is the path of the embedded SQLite database file. Example: ```bash # MySQL # https://github.com/go-sql-driver/mysql#dsn-data-source-name WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true # PostgreSQL # https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable ``` --- ### DATABASE_DATASOURCE_FILE - Name: `WOODPECKER_DATABASE_DATASOURCE_FILE` - Default: none Read the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath --- ### PROMETHEUS_AUTH_TOKEN - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN` - Default: none Token to secure the Prometheus metrics endpoint. Must be set to enable the endpoint. --- ### PROMETHEUS_AUTH_TOKEN_FILE - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE` - Default: none Read the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath --- ### STATUS_CONTEXT - Name: `WOODPECKER_STATUS_CONTEXT` - Default: `ci/woodpecker` Context prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository. --- ### STATUS_CONTEXT_FORMAT - Name: `WOODPECKER_STATUS_CONTEXT_FORMAT` - Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}` Template for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language. Supported variables: - `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`) - `event`: the event which started the pipeline - `workflow`: the workflow's name - `owner`: the repo's owner - `repo`: the repo's name --- ### CONFIG_SERVICE_ENDPOINT - Name: `WOODPECKER_CONFIG_SERVICE_ENDPOINT` - Default: none Specify a configuration service endpoint, see [Configuration Extension](#external-configuration-api) --- ### EXTENSIONS_ALLOWED_HOSTS - Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` - Default: `external` Comma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list. --- ### FORGE_TIMEOUT - Name: `WOODPECKER_FORGE_TIMEOUT` - Default: 5s Specify timeout when fetching the Woodpecker configuration from forge. See for syntax reference. --- ### FORGE_RETRY - Name: `WOODPECKER_FORGE_RETRY` - Default: 3 Specify how many retries of fetching the Woodpecker configuration from a forge are done before we fail. --- ### ENABLE_SWAGGER - Name: `WOODPECKER_ENABLE_SWAGGER` - Default: true Enable the Swagger UI for API documentation. --- ### DISABLE_VERSION_CHECK - Name: `WOODPECKER_DISABLE_VERSION_CHECK` - Default: false Disable version check in admin web UI. --- ### LOG_STORE - Name: `WOODPECKER_LOG_STORE` - Default: `database` Where to store logs. Possible values: - `database`: stores the logs in the database - `file`: stores logs in JSON files on the files system - `addon`: uses an [addon](./100-addons.md#log) to store logs --- ### LOG_STORE_FILE_PATH - Name: `WOODPECKER_LOG_STORE_FILE_PATH` - Default: none If [`WOODPECKER_LOG_STORE`](#log_store) is: - `file`: Directory to store logs in - `addon`: The path to the addon executable --- ### EXPERT_WEBHOOK_HOST - Name: `WOODPECKER_EXPERT_WEBHOOK_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `://[/]`. --- ### EXPERT_FORGE_OAUTH_HOST - Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified public forge URL, used if forge url is not a public URL. Format: `://[/]`. --- ### GITHUB\_\* See [GitHub configuration](./12-forges/20-github.md#configuration) --- ### GITEA\_\* See [Gitea configuration](./12-forges/30-gitea.md#configuration) --- ### BITBUCKET\_\* See [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration) --- ### GITLAB\_\* See [GitLab configuration](./12-forges/40-gitlab.md#configuration) ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/100-addons.md ================================================ # Addons Addons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service. :::warning Addon forges are still experimental. Their implementation can change and break at any time. ::: :::danger You must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information. ::: ## Usage To use an addon forge, download the correct addon version. ### Forge Use this in your `.env`: ```ini WOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file ``` In case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`. #### List of addon forges - [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws). ### Log Use this in your `.env`: ```ini WOODPECKER_LOG_STORE=addon WOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file ``` ## Developing addon forges See [Addons](../../92-development/100-addons.md). ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/10-docker.md ================================================ --- toc_max_heading_level: 2 --- # Docker This is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent. ## Private registries Woodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config). To add your credential helper to the Woodpecker server container you could use the following code to build a custom image: ```dockerfile FROM woodpeckerci/woodpecker-server:latest-alpine RUN apk add -U --no-cache docker-credential-ecr-login ``` ## Step specific configuration ### Run user By default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group: ```yaml steps: - name: example image: alpine commands: - whoami backend_options: docker: user: 65534:65534 ``` The syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag. ## Tips and tricks ### Image cleanup The agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner. :::danger The following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation. ::: - Remove all unused images ```bash docker image rm $(docker images --filter "dangling=true" -q --no-trunc) ``` - Remove Woodpecker volumes ```bash docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true -q) ``` ### Podman There is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog). ## Environment variables ### BACKEND_DOCKER_NETWORK - Name: `WOODPECKER_BACKEND_DOCKER_NETWORK` - Default: none Set to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other! --- ### BACKEND_DOCKER_ENABLE_IPV6 - Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6` - Default: `false` Enable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6. --- ### BACKEND_DOCKER_VOLUMES - Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES` - Default: none List of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA certificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`. --- ### BACKEND_DOCKER_LIMIT_MEM_SWAP - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP` - Default: `0` The maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_MEM - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM` - Default: `0` The maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_SHM_SIZE - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE` - Default: `0` The maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_QUOTA - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA` - Default: `0` The number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_SHARES - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES` - Default: `0` The relative weight vs. other containers. --- ### BACKEND_DOCKER_LIMIT_CPU_SET - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET` - Default: none Comma-separated list to limit the specific CPUs or cores a pipeline container can use. Example: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2` ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/20-kubernetes.md ================================================ --- toc_max_heading_level: 2 --- # Kubernetes The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps. ## Metadata labels Woodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies. The following metadata labels are supported: - `woodpecker-ci.org/forge-id` - `woodpecker-ci.org/repo-forge-id` - `woodpecker-ci.org/repo-id` - `woodpecker-ci.org/repo-name` - `woodpecker-ci.org/repo-full-name` - `woodpecker-ci.org/branch` - `woodpecker-ci.org/org-id` - `woodpecker-ci.org/task-uuid` - `woodpecker-ci.org/step` ## Private registries In addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML. Place these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`. ## Step specific configuration ### Resources The Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory. We recommend to add a `resources` definition to all steps to ensure efficient scheduling. Here is an example definition with an arbitrary `resources` definition below the `backend_options` section: ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: resources: requests: memory: 200Mi cpu: 100m limits: memory: 400Mi cpu: 1000m ``` You can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis. ### Runtime class `runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes. ### Service account `serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts. ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: # Use the service account `default` in the current namespace. # This usually the same as wherever woodpecker is deployed. serviceAccountName: default ``` To give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) ### Node selector `nodeSelector` specifies the labels which are used to select the node on which the step will be executed. Labels defined here will be appended to a list which already contains `"kubernetes.io/arch"`. By default `"kubernetes.io/arch"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`. Without a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures. To overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`. A practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture. In this case, one must define an arbitrary key in the matrix section of the respective matrix element: ```yaml matrix: include: - NAME: runner1 ARCH: arm64 ``` And then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var: ```yaml [...] backend_options: kubernetes: nodeSelector: kubernetes.io/arch: "${ARCH}" ``` You can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent or [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis. ### Tolerations When you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations. Example pipeline configuration: ```yaml steps: - name: build image: golang commands: - go get - go build - go test backend_options: kubernetes: serviceAccountName: 'my-service-account' resources: requests: memory: 128Mi cpu: 1000m limits: memory: 256Mi nodeSelector: beta.kubernetes.io/instance-type: Standard_D2_v3 tolerations: - key: 'key1' operator: 'Equal' value: 'value1' effect: 'NoSchedule' tolerationSeconds: 3600 ``` ### Volumes To mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option. Persistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference. _If your PVC is not highly available or NFS-based, you may also need to integrate affinity settings to ensure that your steps are executed on the correct node._ NOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver: ```yaml accessModes: - ReadWriteMany ``` Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step: ```yaml steps: - name: "Restore Cache" image: meltwater/drone-cache volumes: - woodpecker-cache:/woodpecker/src/cache settings: mount: - "woodpecker-cache" [...] ``` Or as follows when using a normal image: ```yaml steps: - name: "Edit cache" image: alpine:latest volumes: - woodpecker-cache:/woodpecker/src/cache commands: - echo "Hello World" > /woodpecker/src/cache/output.txt [...] ``` ### Security context Use the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step: ```yaml steps: - name: test image: alpine commands: - echo Hello world backend_options: kubernetes: securityContext: runAsUser: 999 runAsGroup: 999 privileged: true [...] ``` Note that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object. By default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the configuration shown above will result in something like the following Pod spec: ```yaml kind: Pod spec: securityContext: runAsUser: 999 runAsGroup: 999 containers: - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0 image: alpine securityContext: privileged: true [...] ``` You can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile. ```yaml backend_options: kubernetes: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/audit.json ``` or restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile ```yaml backend_options: kubernetes: securityContext: apparmorProfile: type: Localhost localhostProfile: k8s-apparmor-example-deny-write ``` or configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always') ```yaml backend_options: kubernetes: securityContext: fsGroupChangePolicy: OnRootMismatch ``` :::note The feature requires Kubernetes v1.30 or above. ::: ### Annotations and labels You can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration: ```yaml backend_options: kubernetes: annotations: workflow-group: alpha io.kubernetes.cri-o.Devices: /dev/fuse labels: environment: ci app.kubernetes.io/name: builder ``` In order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent: [WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step). ## Tips and tricks ### CRI-O CRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration: ```yaml workspace: base: '/woodpecker' path: '/' ``` See [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details. ### `KUBERNETES_SERVICE_HOST` environment variable Like the below env vars used for configuration, this can be set in the environment for configuration of the agent. It configures the address of the Kubernetes API server to connect to. If running the agent within Kubernetes, this will already be set and you don't have to add it manually. ## Environment variables These env vars can be set in the `env:` sections of the agent. --- ### BACKEND_K8S_NAMESPACE - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE` - Default: `woodpecker` The namespace to create worker Pods in. --- ### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION` - Default: `false` Enables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation. With this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker. ### BACKEND_K8S_VOLUME_SIZE - Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE` - Default: `10G` The volume size of the pipeline volume. --- ### BACKEND_K8S_STORAGE_CLASS - Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS` - Default: none The storage class to use for the pipeline volume. --- ### BACKEND_K8S_STORAGE_RWX - Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX` - Default: `true` Determines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead. --- ### BACKEND_K8S_POD_LABELS - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS` - Default: none Additional labels to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-label":"test-value"}`. --- ### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP` - Default: `false` Determines if additional Pod labels can be defined from a step's backend options. --- ### BACKEND_K8S_POD_ANNOTATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS` - Default: none Additional annotations to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-annotation":"test-value"}`. --- ### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP` - Default: `false` Determines if Pod annotations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_TOLERATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS` - Default: none Additional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{"effect":"NoSchedule","key":"jobs","operator":"Exists"}]`. --- ### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP` - Default: `true` Determines if Pod tolerations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_NODE_SELECTOR - Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR` - Default: none Additional node selector to apply to worker pods. Must be a YAML object, e.g. `{"topology.kubernetes.io/region":"eu-central-1"}`. --- ### BACKEND_K8S_SECCTX_NONROOT - Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT` - Default: `false` Determines if containers must be required to run as non-root users. --- ### BACKEND_K8S_PULL_SECRET_NAMES - Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` - Default: none Secret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/). --- ### BACKEND_K8S_PRIORITY_CLASS - Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS` - Default: none, which will use the default priority class configured in Kubernetes Which [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/30-local.md ================================================ --- toc_max_heading_level: 2 --- # Local :::danger The local backend executes pipelines on the local system without any isolation. ::: :::note Currently we do not support [services](../../../20-usage/60-services.md) for this backend. [Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095). ::: Since the commands run directly in the same context as the agent (same user, same filesystem), a malicious pipeline could be used to access the agent configuration especially the `WOODPECKER_AGENT_SECRET` variable. It is recommended to use this backend only for private setup where the code and pipeline can be trusted. It should not be used in a public instance where anyone can submit code or add new repositories. The agent should not run as a privileged user (root). The local backend will use a random directory in `$TMPDIR` to store the cloned code and execute commands. In order to use this backend, you need to download (or build) the [agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine. ## Step specific configuration ### Shell The `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is used to run the commands. ```yaml title=".woodpecker.yaml" steps: - name: build image: bash commands: [...] ``` ### Plugins ```yaml steps: - name: build image: /usr/bin/tree ``` If no commands are provided, plugins are treated in the usual manner. In the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path. ## Environment variables ### BACKEND_LOCAL_TEMP_DIR - Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` - Default: default temp directory Directory to create folders for workflows. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/50-custom.md ================================================ # Custom If none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend: ```go package main import ( "go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core" backendTypes "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func main() { core.RunAgent([]backendTypes.Backend{ yourBackend, }) } ``` ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/11-backends/_category_.yaml ================================================ label: 'Backends' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/11-overview.md ================================================ # Forges ## Supported features | Feature | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) | | ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- | | Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | Event: Deploy¹ | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | | [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | [Multiple workflows](../../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [when.path filter](../../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | ¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks. In addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/20-github.md ================================================ --- toc_max_heading_level: 2 --- # GitHub Woodpecker comes with built-in support for GitHub and GitHub Enterprise. To use Woodpecker with GitHub the following environment variables should be set for the server component: ```ini WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID WOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET ``` You will get these values from GitHub when you register your OAuth application. To do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App. :::warning Do not use a "GitHub App" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically) ::: ## App Settings - Name: An arbitrary name for your App - Homepage URL: The URL of your Woodpecker instance - Callback URL: `https:///authorize` - (optional) Upload the Woodpecker Logo: ## Client Secret Creation After your App has been created, you can generate a client secret. Use this one for the `WOODPECKER_GITHUB_SECRET` environment variable. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITHUB - Name: `WOODPECKER_GITHUB` - Default: `false` Enables the GitHub driver. --- ### GITHUB_URL - Name: `WOODPECKER_GITHUB_URL` - Default: `https://github.com` Configures the GitHub server address. --- ### GITHUB_CLIENT - Name: `WOODPECKER_GITHUB_CLIENT` - Default: none Configures the GitHub OAuth client id to authorize access. --- ### GITHUB_CLIENT_FILE - Name: `WOODPECKER_GITHUB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath. --- ### GITHUB_SECRET - Name: `WOODPECKER_GITHUB_SECRET` - Default: none Configures the GitHub OAuth client secret. This is used to authorize access. --- ### GITHUB_SECRET_FILE - Name: `WOODPECKER_GITHUB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath. --- ### GITHUB_MERGE_REF - Name: `WOODPECKER_GITHUB_MERGE_REF` - Default: `true` --- ### GITHUB_SKIP_VERIFY - Name: `WOODPECKER_GITHUB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### GITHUB_PUBLIC_ONLY - Name: `WOODPECKER_GITHUB_PUBLIC_ONLY` - Default: `false` Configures the GitHub OAuth client to only obtain a token that can manage public repositories. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/30-gitea.md ================================================ --- toc_max_heading_level: 2 --- # Gitea Woodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITEA=true WOODPECKER_GITEA_URL=YOUR_GITEA_URL WOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT WOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET ``` ## Gitea on the same host with containers If you have Gitea also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `gitea`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea ``` ## Registration Register your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. If you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook). ![gitea oauth setup](gitea_oauth.gif) :::warning Make sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITEA - Name: `WOODPECKER_GITEA` - Default: `false` Enables the Gitea driver. --- ### GITEA_URL - Name: `WOODPECKER_GITEA_URL` - Default: `https://try.gitea.io` Configures the Gitea server address. --- ### GITEA_CLIENT - Name: `WOODPECKER_GITEA_CLIENT` - Default: none Configures the Gitea OAuth client id. This is used to authorize access. --- ### GITEA_CLIENT_FILE - Name: `WOODPECKER_GITEA_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath --- ### GITEA_SECRET - Name: `WOODPECKER_GITEA_SECRET` - Default: none Configures the Gitea OAuth client secret. This is used to authorize access. --- ### GITEA_SECRET_FILE - Name: `WOODPECKER_GITEA_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITEA_SECRET` from the specified filepath --- ### GITEA_SKIP_VERIFY - Name: `WOODPECKER_GITEA_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/35-forgejo.md ================================================ --- toc_max_heading_level: 2 --- # Forgejo Woodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_FORGEJO=true WOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL WOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT WOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET ``` ## Forgejo on the same host with containers If you have Forgejo also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `forgejo`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo ``` ## Registration Register your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. If you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook). ![forgejo oauth setup](gitea_oauth.gif) :::warning Make sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### FORGEJO - Name: `WOODPECKER_FORGEJO` - Default: `false` Enables the Forgejo driver. --- ### FORGEJO_URL - Name: `WOODPECKER_FORGEJO_URL` - Default: `https://next.forgejo.org` Configures the Forgejo server address. --- ### FORGEJO_CLIENT - Name: `WOODPECKER_FORGEJO_CLIENT` - Default: none Configures the Forgejo OAuth client id. This is used to authorize access. --- ### FORGEJO_CLIENT_FILE - Name: `WOODPECKER_FORGEJO_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath --- ### FORGEJO_SECRET - Name: `WOODPECKER_FORGEJO_SECRET` - Default: none Configures the Forgejo OAuth client secret. This is used to authorize access. --- ### FORGEJO_SECRET_FILE - Name: `WOODPECKER_FORGEJO_SECRET_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath --- ### FORGEJO_SKIP_VERIFY - Name: `WOODPECKER_FORGEJO_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/40-gitlab.md ================================================ --- toc_max_heading_level: 2 --- # GitLab Woodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITLAB=true WOODPECKER_GITLAB_URL=http://gitlab.mycompany.com WOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82 WOODPECKER_GITLAB_SECRET=30f5064039e6b359e075 ``` ## Registration You must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application. Please use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application. If you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITLAB - Name: `WOODPECKER_GITLAB` - Default: `false` Enables the GitLab driver. --- ### GITLAB_URL - Name: `WOODPECKER_GITLAB_URL` - Default: `https://gitlab.com` Configures the GitLab server address. --- ### GITLAB_CLIENT - Name: `WOODPECKER_GITLAB_CLIENT` - Default: none Configures the GitLab OAuth client id. This is used to authorize access. --- ### GITLAB_CLIENT_FILE - Name: `WOODPECKER_GITLAB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath --- ### GITLAB_SECRET - Name: `WOODPECKER_GITLAB_SECRET` - Default: none Configures the GitLab OAuth client secret. This is used to authorize access. --- ### GITLAB_SECRET_FILE - Name: `WOODPECKER_GITLAB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath --- ### GITLAB_SKIP_VERIFY - Name: `WOODPECKER_GITLAB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/50-bitbucket.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Woodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_BITBUCKET=true WOODPECKER_BITBUCKET_CLIENT=... # called "Key" in Bitbucket WOODPECKER_BITBUCKET_SECRET=... ``` ## Registration You must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`). Please set a name and set the `Callback URL` like this: ```uri https:///authorize ``` ![bitbucket oauth setup](bitbucket_oauth.png) Please also be sure to check the following permissions: - Account: Email, Read - Workspace membership: Read - Projects: Read - Repositories: Read - Pull requests: Read - Webhooks: Read and Write ![bitbucket permissions](bitbucket_permissions.png) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET - Name: `WOODPECKER_BITBUCKET` - Default: `false` Enables the Bitbucket driver. --- ### BITBUCKET_CLIENT - Name: `WOODPECKER_BITBUCKET_CLIENT` - Default: none Configures the Bitbucket OAuth client key. This is used to authorize access. --- ### BITBUCKET_CLIENT_FILE - Name: `WOODPECKER_BITBUCKET_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath --- ### BITBUCKET_SECRET - Name: `WOODPECKER_BITBUCKET_SECRET` - Default: none Configures the Bitbucket OAuth client secret. This is used to authorize access. --- ### BITBUCKET_SECRET_FILE - Name: `WOODPECKER_BITBUCKET_SECRET_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath ## Known Issues Bitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details. ## Missing Features Path filters for pull requests are not supported. We are interested in patches to include this functionality. If you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de). ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Datacenter / Server :::warning Woodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash. ::: To enable Bitbucket Server you should configure the Woodpecker container using the following environment variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_BITBUCKET_DC=true + - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo + - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar + - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx + - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy + - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com + - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true woodpecker-agent: [...] ``` ## Service Account Woodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories. ## Registration Woodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose "Application Links" and then "Create link". Woodpecker should be listed as "External Application" and the direction should be set to "Incoming". Note the client id and client secret of the registration to be used in the configuration of Woodpecker. See also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html). ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET_DC - Name: `WOODPECKER_BITBUCKET_DC` - Default: `false` Enables the Bitbucket Server driver. --- ### BITBUCKET_DC_URL - Name: `WOODPECKER_BITBUCKET_DC_URL` - Default: none Configures the Bitbucket Server address. --- ### BITBUCKET_DC_CLIENT_ID - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID` - Default: none Configures your Bitbucket Server OAUth 2.0 client id. --- ### BITBUCKET_DC_CLIENT_SECRET - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET` - Default: none Configures your Bitbucket Server OAUth 2.0 client secret. --- ### BITBUCKET_DC_GIT_USERNAME - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` - Default: none This username is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_USERNAME_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath --- ### BITBUCKET_DC_GIT_PASSWORD - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` - Default: none The password is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_PASSWORD_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath --- ### BITBUCKET_DC_SKIP_VERIFY - Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN - Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN` - Default: `false` When enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/12-forges/_category_.yaml ================================================ label: 'Forges' collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/30-agent.md ================================================ --- toc_max_heading_level: 3 --- # Agent Agents are configured by the command line or environment variables. At the minimum you need the following information: ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" ``` The following are automatically set and can be overridden: - `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname - `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1 ## Workflows per agent By default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent. ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" WOODPECKER_MAX_WORKFLOWS=4 ``` ## Agent registration When the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before. There are two types of tokens to connect an agent to the server: ### Using system token A _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents. In that case registration process would be as following: 1. The first time the agent communicates with the server, it is using the system token 1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent 1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`) 1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server ### Using agent token An _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`. To get an _agent token_ you have to register the agent manually in the server using the UI: 1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent` ![Agent creation](./new-agent-registration.png) ![Agent created](./new-agent-created.png) 1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET` 1. The agent will connect to the server using the provided token and will update its status in the UI: ![Agent connected](./new-agent-connected.png) ## Environment variables ### SERVER - Name: `WOODPECKER_SERVER` - Default: `localhost:9000` Configures gRPC address of the server. --- ### USERNAME - Name: `WOODPECKER_USERNAME` - Default: `x-oauth-basic` The gRPC username. --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf` --- ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOSTNAME - Name: `WOODPECKER_HOSTNAME` - Default: none Configures the agent hostname. --- ### AGENT_CONFIG_FILE - Name: `WOODPECKER_AGENT_CONFIG_FILE` - Default: `/etc/woodpecker/agent.conf` Configures the path of the agent config file. --- ### MAX_WORKFLOWS - Name: `WOODPECKER_MAX_WORKFLOWS` - Default: `1` Configures the number of parallel workflows. --- ### AGENT_LABELS - Name: `WOODPECKER_AGENT_LABELS` - Default: none Configures custom labels for the agent, to let workflows filter by it. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. If you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched. By default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels). --- ### HEALTHCHECK - Name: `WOODPECKER_HEALTHCHECK` - Default: `true` Enable healthcheck endpoint. --- ### HEALTHCHECK_ADDR - Name: `WOODPECKER_HEALTHCHECK_ADDR` - Default: `:3000` Configures healthcheck endpoint address. --- ### KEEPALIVE_TIME - Name: `WOODPECKER_KEEPALIVE_TIME` - Default: none After a duration of this time of no activity, the agent pings the server to check if the transport is still alive. --- ### KEEPALIVE_TIMEOUT - Name: `WOODPECKER_KEEPALIVE_TIMEOUT` - Default: `20s` After pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity. --- ### GRPC_SECURE - Name: `WOODPECKER_GRPC_SECURE` - Default: `false` Configures if the connection to `WOODPECKER_SERVER` should be made using a secure transport. --- ### GRPC_VERIFY - Name: `WOODPECKER_GRPC_VERIFY` - Default: `true` Configures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`. --- ### BACKEND - Name: `WOODPECKER_BACKEND` - Default: `auto-detect` Configures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`. ### BACKEND_DOCKER\_\* See [Docker backend configuration](./11-backends/10-docker.md#environment-variables) --- ### BACKEND_K8S\_\* See [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables) --- ### BACKEND_LOCAL\_\* See [Local backend configuration](./11-backends/30-local.md#environment-variables) ### Advanced Settings :::warning Only change these If you know what you do. ::: #### CONNECT_RETRY_COUNT - Name: `WOODPECKER_CONNECT_RETRY_COUNT` - Default: `5` Configures number of times agent retries to connect to the server. #### CONNECT_RETRY_DELAY - Name: `WOODPECKER_CONNECT_RETRY_DELAY` - Default: `2s` Configures delay between agent connection retries to the server. ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/40-autoscaler.md ================================================ # Autoscaler If your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler). Please note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap). ## Setup ### docker compose If you are using docker compose you can add the following to your `docker-compose.yaml` file: ```yaml services: woodpecker-server: image: woodpeckerci/woodpecker-server:next [...] woodpecker-autoscaler: image: woodpeckerci/autoscaler:next restart: always depends_on: - woodpecker-server environment: - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user - WOODPECKER_MIN_AGENTS=0 - WOODPECKER_MAX_AGENTS=3 - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include "https://" in the value. - WOODPECKER_GRPC_SECURE=true - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud ``` ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/10-configuration/_category_.yaml ================================================ label: 'Configuration' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.12/30-administration/_category_.yaml ================================================ label: 'Administration' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.12/40-cli.md ================================================ # CLI # NAME woodpecker-cli - command line utility # SYNOPSIS woodpecker-cli ``` [--config|-c]=[value] [--disable-update-check] [--log-file]=[value] [--log-level]=[value] [--nocolor] [--pretty] [--server|-s]=[value] [--skip-verify] [--socks-proxy-off] [--socks-proxy]=[value] [--token|-t]=[value] ``` # DESCRIPTION Woodpecker command line utility **Usage**: ``` woodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...] ``` # GLOBAL OPTIONS **--config, -c**="": path to config file **--disable-update-check**: disable update check (default: false) **--log-file**="": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr) **--log-level**="": set logging level (default: info) **--nocolor**: disable colored debug output, only has effect if pretty output is set too (default: false) **--pretty**: enable pretty-printed debug output (default: true) **--server, -s**="": server address **--skip-verify**: skip ssl verification (default: false) **--socks-proxy**="": socks proxy address **--socks-proxy-off**: socks proxy ignored (default: false) **--token, -t**="": server auth token # COMMANDS ## admin manage server settings ### log-level retrieve log level from server, or set it with [level] ### org manage organizations #### ls list organizations **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nOrganization ID: {{ .ID }}\n) ### registry manage global registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username ### secret manage global secrets #### add add a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--value**="": secret value #### rm remove a secret **--name**="": secret name #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name #### update update a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--value**="": secret value ### user manage users #### add add a user #### ls list all users **--format**="": format output (default: {{ .Login }}) #### rm remove a user #### show show user information **--format**="": format output (default: User: {{ .Login }}\nEmail: {{ .Email }}) ## exec execute a local pipeline **--backend-docker-api-version**="": the version of the API to reach, leave empty for latest. **--backend-docker-cert**="": path to load the TLS certificates for connecting to docker server **--backend-docker-host**="": path to docker socket or url to the docker server **--backend-docker-ipv6**: backend docker enable IPV6 (default: false) **--backend-docker-limit-cpu-quota**="": impose a cpu quota (default: 0) **--backend-docker-limit-cpu-set**="": set the cpus allowed to execute containers **--backend-docker-limit-cpu-shares**="": change the cpu shares (default: 0) **--backend-docker-limit-mem**="": maximum memory allowed in bytes (default: 0) **--backend-docker-limit-mem-swap**="": maximum memory used for swap in bytes (default: 0) **--backend-docker-limit-shm-size**="": docker /dev/shm allowed in bytes (default: 0) **--backend-docker-network**="": backend docker network **--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server (default: true) **--backend-docker-volumes**="": backend docker volumes (comma separated) **--backend-engine**="": backend engine to run pipelines on (default: auto-detect) **--backend-http-proxy**="": if set, pass the environment variable down as "HTTP_PROXY" to steps **--backend-https-proxy**="": if set, pass the environment variable down as "HTTPS_PROXY" to steps **--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps (default: false) **--backend-k8s-namespace**="": backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name. (default: woodpecker) **--backend-k8s-namespace-per-org**: Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization. (default: false) **--backend-k8s-pod-annotations**="": backend k8s additional Agent-wide worker pod annotations **--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options (default: false) **--backend-k8s-pod-image-pull-secret-names**="": backend k8s pull secret names for private registries **--backend-k8s-pod-labels**="": backend k8s additional Agent-wide worker pod labels **--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options (default: false) **--backend-k8s-pod-node-selector**="": backend k8s Agent-wide worker pod node selector **--backend-k8s-pod-tolerations**="": backend k8s Agent-wide worker pod tolerations **--backend-k8s-pod-tolerations-allow-from-step**: whether to allow using tolerations from step's backend options (default: true) **--backend-k8s-priority-class**="": which kubernetes priority class to assign to created job pods **--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option (default: false) **--backend-k8s-storage-class**="": backend k8s storage class **--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) (default: true) **--backend-k8s-volume-size**="": backend k8s volume size (default 10G) (default: 10G) **--backend-local-temp-dir**="": set a different temp dir to clone workflows into (default: system temporary directory) **--backend-no-proxy**="": if set, pass the environment variable down as "NO_PROXY" to steps **--commit-author-avatar**="": Set the metadata environment variable "CI_COMMIT_AUTHOR_AVATAR". **--commit-author-email**="": Set the metadata environment variable "CI_COMMIT_AUTHOR_EMAIL". **--commit-author-name**="": Set the metadata environment variable "CI_COMMIT_AUTHOR". **--commit-branch**="": Set the metadata environment variable "CI_COMMIT_BRANCH". (default: main) **--commit-message**="": Set the metadata environment variable "CI_COMMIT_MESSAGE". **--commit-pull-labels**="": Set the metadata environment variable "CI_COMMIT_PULL_REQUEST_LABELS". **--commit-pull-milestone**="": Set the metadata environment variable "CI_COMMIT_PULL_REQUEST_MILESTONE". **--commit-ref**="": Set the metadata environment variable "CI_COMMIT_REF". **--commit-refspec**="": Set the metadata environment variable "CI_COMMIT_REFSPEC". **--commit-release-is-pre**: Set the metadata environment variable "CI_COMMIT_PRERELEASE". (default: false) **--commit-sha**="": Set the metadata environment variable "CI_COMMIT_SHA". **--env**="": Set the metadata environment variable "CI_ENV". **--forge-type**="": Set the metadata environment variable "CI_FORGE_TYPE". **--forge-url**="": Set the metadata environment variable "CI_FORGE_URL". **--local**: run from local directory (default: true) **--metadata-file**="": path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags **--netrc-machine**="": **--netrc-password**="": **--netrc-username**="": **--network**="": external networks **--pipeline-changed-files**="": Set the metadata environment variable "CI_PIPELINE_FILES", either json formatted list of strings, or comma separated string list. **--pipeline-created**="": Set the metadata environment variable "CI_PIPELINE_CREATED". (default: 0) **--pipeline-deploy-task**="": Set the metadata environment variable "CI_PIPELINE_DEPLOY_TASK". **--pipeline-deploy-to**="": Set the metadata environment variable "CI_PIPELINE_DEPLOY_TARGET". **--pipeline-event**="": Set the metadata environment variable "CI_PIPELINE_EVENT". (default: manual) **--pipeline-number**="": Set the metadata environment variable "CI_PIPELINE_NUMBER". (default: 0) **--pipeline-parent**="": Set the metadata environment variable "CI_PIPELINE_PARENT". (default: 0) **--pipeline-started**="": Set the metadata environment variable "CI_PIPELINE_STARTED". (default: 0) **--pipeline-url**="": Set the metadata environment variable "CI_PIPELINE_FORGE_URL". **--plugins-privileged**="": Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none **--prev-commit-author-avatar**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR_AVATAR". **--prev-commit-author-email**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR_EMAIL". **--prev-commit-author-name**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR". **--prev-commit-branch**="": Set the metadata environment variable "CI_PREV_COMMIT_BRANCH". **--prev-commit-message**="": Set the metadata environment variable "CI_PREV_COMMIT_MESSAGE". **--prev-commit-ref**="": Set the metadata environment variable "CI_PREV_COMMIT_REF". **--prev-commit-refspec**="": Set the metadata environment variable "CI_PREV_COMMIT_REFSPEC". **--prev-commit-sha**="": Set the metadata environment variable "CI_PREV_COMMIT_SHA". **--prev-pipeline-created**="": Set the metadata environment variable "CI_PREV_PIPELINE_CREATED". (default: 0) **--prev-pipeline-deploy-task**="": Set the metadata environment variable "CI_PREV_PIPELINE_DEPLOY_TASK". **--prev-pipeline-deploy-to**="": Set the metadata environment variable "CI_PREV_PIPELINE_DEPLOY_TARGET". **--prev-pipeline-event**="": Set the metadata environment variable "CI_PREV_PIPELINE_EVENT". **--prev-pipeline-finished**="": Set the metadata environment variable "CI_PREV_PIPELINE_FINISHED". (default: 0) **--prev-pipeline-number**="": Set the metadata environment variable "CI_PREV_PIPELINE_NUMBER". (default: 0) **--prev-pipeline-started**="": Set the metadata environment variable "CI_PREV_PIPELINE_STARTED". (default: 0) **--prev-pipeline-status**="": Set the metadata environment variable "CI_PREV_PIPELINE_STATUS". **--prev-pipeline-url**="": Set the metadata environment variable "CI_PREV_PIPELINE_FORGE_URL". **--repo**="": Set the full name to derive metadata environment variables "CI_REPO", "CI_REPO_NAME" and "CI_REPO_OWNER". **--repo-clone-ssh-url**="": Set the metadata environment variable "CI_REPO_CLONE_SSH_URL". **--repo-clone-url**="": Set the metadata environment variable "CI_REPO_CLONE_URL". **--repo-default-branch**="": Set the metadata environment variable "CI_REPO_DEFAULT_BRANCH". (default: main) **--repo-path**="": path to local repository **--repo-private**="": Set the metadata environment variable "CI_REPO_PRIVATE". **--repo-remote-id**="": Set the metadata environment variable "CI_REPO_REMOTE_ID". **--repo-trusted-network**: Set the metadata environment variable "CI_REPO_TRUSTED_NETWORK". (default: false) **--repo-trusted-security**: Set the metadata environment variable "CI_REPO_TRUSTED_SECURITY". (default: false) **--repo-trusted-volumes**: Set the metadata environment variable "CI_REPO_TRUSTED_VOLUMES". (default: false) **--repo-url**="": Set the metadata environment variable "CI_REPO_URL". **--secrets**="": map of secrets, ex. 'secret="val",secret2="value2"' **--secrets**="": path to yaml file with secrets map **--system-host**="": Set the metadata environment variable "CI_SYSTEM_HOST". **--system-name**="": Set the metadata environment variable "CI_SYSTEM_NAME". (default: woodpecker) **--system-platform**="": Set the metadata environment variable "CI_SYSTEM_PLATFORM". **--system-url**="": Set the metadata environment variable "CI_SYSTEM_URL". (default: https://github.com/woodpecker-ci/woodpecker) **--timeout**="": pipeline timeout (default: 1h0m0s) **--volumes**="": pipeline volumes **--workflow-name**="": Set the metadata environment variable "CI_WORKFLOW_NAME". **--workflow-number**="": Set the metadata environment variable "CI_WORKFLOW_NUMBER". (default: 0) **--workspace-base**="": (default: /woodpecker) **--workspace-path**="": (default: src) ## info show information about the current user **--format**="": format output (deprecated) (default: User: {{ .Login }}\nEmail: {{ .Email }}) ## lint lint a pipeline configuration file **--plugins-privileged**="": allow plugins to run in privileged mode, if set empty, there is no **--plugins-trusted-clone**="": plugins that are trusted to handle Git credentials in cloning steps (default: "docker.io/woodpeckerci/plugin-git:2.7.0", "docker.io/woodpeckerci/plugin-git", "quay.io/woodpeckerci/plugin-git") **--strict**: treat warnings as errors (default: false) ## org manage organizations ### registry manage organization registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username ### secret manage secrets #### add add a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--value**="": secret value #### rm remove a secret **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a secret **--event**="": limit secret to these event **--image**="": limit secret to these image **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--value**="": secret value ## pipeline manage pipelines ### approve approve a pipeline ### create create new pipeline **--branch**="": branch to create pipeline from **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) **--var**="": key=value ### decline decline a pipeline ### deploy trigger a pipeline with the 'deployment' event **--branch**="": branch filter **--event**="": event filter (default: push) **--format**="": format output (default: Number: {{ .Number }}\nStatus: {{ .Status }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nMessage: {{ .Message }}\nAuthor: {{ .Author }}\nTarget: {{ .Deploy }}\n) **--param, -p**="": custom parameters to inject into the step environment. Format: KEY=value **--status**="": status filter (default: success) ### last show latest pipeline information **--branch**="": branch name (default: main) **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### ls show pipeline history **--after**="": only return pipelines after this date (RFC3339) **--before**="": only return pipelines before this date (RFC3339) **--branch**="": branch filter **--event**="": event filter **--limit**="": limit the list size (default: 25) **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) **--status**="": status filter ### log manage logs #### purge purge a log #### show show pipeline logs ### ps show pipeline steps **--format**="": format output (default: \x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\x1b[0m\nStep: {{ .step.Name }}\nStarted: {{ .step.Started }}\nStopped: {{ .step.Stopped }}\nType: {{ .step.Type }}\nState: {{ .step.State }}\n) ### purge purge pipelines **--branch**="": remove pipelines of this branch only **--dry-run**: disable non-read api calls (default: false) **--keep-min**="": minimum number of pipelines to keep (default: 10) **--older-than**="": remove pipelines older than the specified time limit (default: 0s) ### queue show pipeline queue **--format**="": format output (default: \x1b[33m{{ .FullName }} #{{ .Number }} \x1b[0m\nStatus: {{ .Status }}\nEvent: {{ .Event }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\nMessage: {{ .Message }}\n) ### show show pipeline information **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### start start a pipeline **--param, -p**="": custom parameters to inject into the step environment. Format: KEY=value ### stop stop a pipeline ## repo manage repositories ### add add a repository ### chown assume ownership of a repository ### cron manage cron jobs #### add add a cron job **--branch**="": cron branch **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule #### rm remove a cron job **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list cron jobs **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show cron job information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a cron job **--branch**="": cron branch **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--id**="": cron id **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule ### ls list all repos **--all**: query all repos, including inactive ones (default: false) **--format**="": format output (deprecated) **--org**="": filter by organization **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### registry manage registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username ### rm remove a repository ### repair repair repository webhooks ### secret manage secrets #### add add a secret **--event**="": limit secret to these events **--image**="": limit secret to these images **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value #### rm remove a secret **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a secret **--event**="": limit secret to these events **--image**="": limit secret to these images **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value ### show show repository information **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### sync synchronize the repository list **--format**="": format output (default: \x1b[33m{{ .FullName }}\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})) ### update update a repository **--config**="": repository configuration path. Example: .woodpecker.yml **--pipeline-counter**="": repository starting pipeline number (default: 0) **--require-approval**="": repository requires approval for **--timeout**="": repository timeout (default: 0s) **--trusted**: repository is trusted (default: false) **--unsafe**: allow unsafe operations (default: false) **--visibility**="": repository visibility ## setup setup the woodpecker-cli for the first time **--server**="": URL of the woodpecker server **--token**="": token to authenticate with the woodpecker server ## update update the woodpecker-cli to the latest version **--force**: force update even if the latest version is already installed (default: false) ================================================ FILE: docs/versioned_docs/version-3.12/92-development/01-getting-started.md ================================================ # Getting started You can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea). ## Gitpod If you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing: - An IDE in the browser or bridged to your local VS-Code or Jetbrains - A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge - A pre-configured Woodpecker server - A single pre-configured Woodpecker agent node - Our docs preview server Start Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`. [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker) ## Preparation for local development ### Install Go Install Golang (>=1.20) as described by [this guide](https://go.dev/doc/install). ### Install make > GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (). Install make on: - Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/) - [Windows](https://stackoverflow.com/a/32127632/8461267) - Mac OS: `brew install make` ### Install Node.js & `pnpm` Install [Node.js (>=20)](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation. For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used. [This guide](https://pnpm.io/installation) describes the installation of `pnpm`. ### Install `pre-commit` (optional) Woodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code. To apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage). ### Create a `.env` file with your development configuration Similar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it. A common config for debugging would look like this: ```ini WOODPECKER_OPEN=true WOODPECKER_ADMIN=your-username WOODPECKER_HOST=http://localhost:8000 # github (sample for a forge config - see /docs/administration/forge/overview for other forges) WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT= WOODPECKER_GITHUB_SECRET= # agent WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system WOODPECKER_MAX_WORKFLOWS=1 # enable if you want to develop the UI # WOODPECKER_DEV_WWW_PROXY=http://localhost:8010 # if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server WOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com # disable health-checks while debugging (normally not needed while developing) WOODPECKER_HEALTHCHECK=false # WOODPECKER_LOG_LEVEL=debug # WOODPECKER_LOG_LEVEL=trace ``` ### Setup OAuth Create an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md). ## Developing with VS Code You can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it. To launch all needed services for local development, you can use "Woodpecker CI" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it. As a starting guide for programming Go with VS Code, you can use this video guide: [![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80) ### Debugging Woodpecker The Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points. ![Woodpecker debugging with VS Code](./vscode-debug.png) ## Testing & linting code To test or lint parts of Woodpecker, you can run one of the following commands: ```bash # test server code make test-server # test agent code make test-agent # test cli code make test-cli # test datastore / database related code like migrations of the server make test-server-datastore # lint go code make lint # lint UI code make lint-frontend # test UI code make test-frontend ``` If you want to test a specific Go file, you can also use: ```bash go test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/ ``` Or you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands: ![Run test via VS-Code](./vscode-run-test.png) ## Run applications from terminal If you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor. ```bash title="start server" go run ./cmd/server ``` ```bash title="start agent" go run ./cmd/agent ``` ```bash title="execute cli command" go run ./cmd/cli [command] ``` ================================================ FILE: docs/versioned_docs/version-3.12/92-development/02-core-ideas.md ================================================ # Core ideas - A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂). - If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle). - What is used most often should be default. - Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md). ## Addons and extensions If you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an [addon](../30-administration/10-configuration/100-addons.md), [extension](../30-administration/10-configuration/10-server.md#external-configuration-api) or an [external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points: - Is your change very specific to your setup and unlikely to be used by anyone else? - Does your change violate the [guidelines](#guidelines)? Both should be false when you open a pull request to get your change into the core repository. ### Guidelines #### Forges A new forge must support these features: - OAuth2 - Webhooks ================================================ FILE: docs/versioned_docs/version-3.12/92-development/03-ui.md ================================================ # UI Development To develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api. ## Setup The UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed). Testing UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files. ![UI Proxy architecture](./ui-proxy.svg) Start the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file. After starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000). ### Usage with remote server If you would like to test your UI changes on a "real-world" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables: - `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org` - `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser Then, open the UI at `http://localhost:8010`. ## Tools and frameworks The following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing. - [Vue 3](https://v3.vuejs.org/) - use `setup` and composition api - place (re-usable) components in `web/src/components/` - views should have a route in `web/src/router.ts` and are located in `web/src/views/` - [Tailwind CSS](https://tailwindcss.com/) - use Tailwind classes where possible - if needed extend the Tailwind config to use new classes - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier) - [Vite](https://vitejs.dev/) (similar to Webpack) - [Typescript](https://www.typescriptlang.org/) - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:) - [eslint](https://eslint.org/) - [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471) ## Messages and Translations Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source. You must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet) For more information about translations see [Translations](./08-translations.md). ================================================ FILE: docs/versioned_docs/version-3.12/92-development/04-docs.md ================================================ # Documentation The documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/). If you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands: ```bash cd docs/ pnpm install # build plugins used by the docs pnpm build:woodpecker-plugins # start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually pnpm start # or build the docs to deploy it to some static page hosting pnpm build ``` ================================================ FILE: docs/versioned_docs/version-3.12/92-development/05-architecture.md ================================================ # Architecture ## Package architecture ![Woodpecker architecture](./woodpecker-architecture.png) ## System architecture ### main package hierarchy | package | meaning | imports | | ------------------ | -------------------------------------------------------------- | ------------------------------------- | | `cmd/**` | parse command-line args & environment to stat server/cli/agent | all other | | `agent/**` | code only agent (remote worker) will need | `pipeline`, `shared` | | `cli/**` | code only cli tool does need | `pipeline`, `shared`, `woodpecker-go` | | `server/**` | code only server will need | `pipeline`, `shared` | | `shared/**` | code shared for all three main tools (go help utils) | only std and external libs | | `woodpecker-go/**` | go client for server rest api | std | ### Server | package | meaning | imports | | -------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server/api/**` | handle web requests from `server/router` | `pipeline`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) | | `server/badges/**` | generate svg badges for pipelines | `../model` | | `server/ccmenu/**` | generate xml ccmenu for pipelines | `../model` | | `server/grpc/**` | gRPC server agents can connect to | `pipeline/rpc/**`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store` | | `server/logging/**` | logging lib for gPRC server to stream logs while running | std | | `server/model/**` | structs for store (db) and api (json) | std | | `server/plugins/**` | plugins for server | `../model`, `../forge` | | `server/pipeline/**` | orchestrate pipelines | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins` | | `server/pubsub/**` | pubsub lib for server to push changes to the WebUI | std | | `server/queue/**` | queue lib for server where agents pull new pipelines from via gRPC | `server/model` | | `server/forge/**` | forge lib for server to connect and handle forge specific stuff | `shared`, `server/model` | | `server/router/**` | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web` | | `server/store/**` | handle database | `server/model` | | `server/shared/**` | TODO: move and split [#974](https://github.com/woodpecker-ci/woodpecker/issues/974) | | | `server/web/**` | server SPA | | - `../` = `server/` ### Agent TODO ### CLI TODO ================================================ FILE: docs/versioned_docs/version-3.12/92-development/06-conventions.md ================================================ # Conventions ## Database naming Database tables are named plural, columns don't have any prefix. Example: Table name `agent`, columns `id`, `name`. ================================================ FILE: docs/versioned_docs/version-3.12/92-development/07-guides.md ================================================ # Guides ## ORM Woodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection. ## Add a new migration Woodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`. :::info Adding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created. ::: :::warning You should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager. ::: To automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start. ## Constants of official images All official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag. ## Building images locally ### Server ```sh ### build web component make vendor cd web/ pnpm install --frozen-lockfile pnpm build cd .. ### define the platforms to build for (e.g. linux/amd64) # (the | is not a typo here) export PLATFORMS='linux|amd64' make cross-compile-server ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push . ``` :::info The `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)). You can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS). ::: ### Agent ```sh ### build the agent make build-agent ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push . ``` ### CLI ```sh ### build the CLI make build-cli ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push . ``` ================================================ FILE: docs/versioned_docs/version-3.12/92-development/08-translations.md ================================================ # Translations To translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.** Translation status Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. ================================================ FILE: docs/versioned_docs/version-3.12/92-development/09-openapi.md ================================================ # Swagger, API Spec and Code Generation Woodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically generate Swagger v2 API specifications and a nice looking Web UI from the source code. Also, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger) and then being using on the community's website documentation. It's paramount important to keep the gin handler function's godoc documentation up-to-date, to always have accurate API documentation. Whenever you change, add or enhance an API endpoint, please update the godoc. You don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools. ## Gin-Handler API documentation guideline Here's a typical example of how annotations for Swagger documentation look like... ```go title="server/api/user.go" // @Summary Get a user // @Description Returns a user with the specified login name. Requires admin rights. // @Router /users/{login} [get] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param foobar query string false "optional foobar parameter" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) ``` ```go title="server/model/user.go" type User struct { ID int64 `json:"id" xorm:"pk autoincr 'user_id'"` // ... } // @name User ``` These guidelines aim to have consistent wording in the OpenAPI doc: - first word after `@Summary` and `@Summary` are always uppercase - `@Summary` has no `.` (dot) at the end of the line - model structs shall use custom short names, to ease life for API consumers, using `@name` - `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI - when pagination is used, `@Param page` and `@Param perPage` must be added manually - `@Param Authorization` is almost always present, there are just a few un-protected endpoints There are many examples in the `server/api` package, which you can use a blueprint. More enhanced information you can find here ### Manual code generation ```bash title="generate the server's Go code containing the OpenAPI" make generate-openapi ``` ```bash title="update the Markdown in the ./docs folder" make generate-docs ``` ================================================ FILE: docs/versioned_docs/version-3.12/92-development/09-testing.md ================================================ # Testing ## Backend ### Unit Tests [We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test) with [`"github.com/stretchr/testify/assert"`](https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert) to simplify testing. ### Integration Tests ### Dummy backend There is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave. To enable it you need to build the agent or cli with the `test` build tag. An example pipeline config would be: ```yaml when: event: manual steps: - name: echo image: dummy commands: echo "hello woodpecker" environment: SLEEP: '1s' services: echo: image: dummy commands: echo "i am a service" ``` This could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`: ```none 9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: service [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9 [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: commands [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 ``` There are also environment variables to alter step behavior: - `SLEEP: 10` will let the step wait 10 seconds - `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands` - `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled) - `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs - `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0 - `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains You can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`. ================================================ FILE: docs/versioned_docs/version-3.12/92-development/100-addons.md ================================================ # Addons The Woodpecker server supports addons for forges and the log store. :::warning Addons are still experimental. Their implementation can change and break at any time. ::: ## Bug reports If you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name. ## Creating addons Addons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin). ### Writing your code This example will use the Go language. Directly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there. In the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument. This will take care of connecting the addon forge to the server. :::note It is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process. ::: ### Example structure This is an example for a forge addon. ```go package main import ( "context" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/forge/addon" forgeTypes "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func main() { addon.Serve(config{}) } type config struct { } // `config` must implement `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge`. You must directly use Woodpecker's packages - see imports above. ``` ### Addon types | Type | Addon package | Service interface | | --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | | Forge | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge` | | Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/service/log".Service` | ================================================ FILE: docs/versioned_docs/version-3.12/92-development/_category_.yaml ================================================ label: 'Development' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.13/10-intro/index.md ================================================ # Welcome to Woodpecker Woodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics. ## Have you ever heard of CI/CD or pipelines? Don't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of checks, tests and routines along the way. A typical pipeline might include the following steps: 1. Running tests 2. Building your application 3. Deploying your application [Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd) ## Do you know containers? If you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/). ## Already have access to a Woodpecker instance? Then you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md). ## Want to start from scratch and deploy your own Woodpecker instance? Woodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance. ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/10-intro.md ================================================ # Your first pipeline Let's get started and create your first pipeline. ## 1. Repository Activation To activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click. ![new repository list](repo-new.png) To enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something that is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.). ## 2. Define first workflow After enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository: ```yaml title=".woodpecker/my-first-workflow.yaml" when: - event: push branch: main steps: - name: build image: debian commands: - echo "This is the build step" - echo "binary-data-123" > executable - name: a-test-step image: golang:1.16 commands: - echo "Testing ..." - ./executable ``` **So what did we do here?** 1. We defined your first workflow file `my-first-workflow.yaml`. 2. This workflow will be executed when a push event happens on the `main` branch, because we added a filter using the `when` section: ```diff + when: + - event: push + branch: main ... ``` 3. We defined two steps: `build` and `a-test-step` The steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`. In the `build` step we use the `debian` image and build a "binary file" called `executable`. In the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it. You can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to: ```diff steps: - name: build - image: debian + image: my-company/image-with-aws_cli commands: - aws help ``` ## 3. Push the file and trigger first pipeline If you push this file to your repository now, Woodpecker will already execute your first pipeline. You can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository. ![pipeline view](./pipeline.png) As you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps. This for example allows the first step to build your application using your source code and as the second step will receive the same workspace it can use the previously built binary and test it. ## 4. Use a plugin for reusable tasks Sometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md). If you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline: ```yaml steps: # ... - name: upload image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name access_key: a50d28f4dd477bc184fbd10b376de753 secret_key: from_secret: aws_secret_key source: public/**/* target: /target/location ``` To configure a plugin you can use the `settings` section. Sometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md). Similar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed. Learn more about [plugins](./51-plugins/51-overview.md). As you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md). ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/100-troubleshooting.md ================================================ # Troubleshooting ## How to debug clone issues (And what to do with an error message like `fatal: could not read Username for 'https://': No such device or address`) This error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`: ```ini WOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true ``` If that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container "hang": ```yaml skip_clone: true steps: build: image: debian:stable-backports commands: - apt update - apt install -y inetutils-ping wget - ping -c 4 git.example.com - wget git.example.com - sleep 9999999 ``` Get the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline: ```bash git init git remote add origin https://git.example.com/username/repo.git git fetch --no-tags origin +refs/heads/branch: ``` (replace the url AND the branch with the correct values, use your username and password as log in values) ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/15-terminology/architecture.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 226, "versionNonce": 1002880859, "isDeleted": false, "id": "UczUX5VuNnCB1rVvUJVfm", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.098092529257, "y": 320.8758615860986, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 472.8823858375721, "height": 183.19688715994928, "seed": 917720693, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 161, "versionNonce": 286006267, "isDeleted": false, "id": "sKPZmBSWUdAYfBs4ByItH", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 539.5451038202509, "y": 345.2419383247636, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 82.46875, "height": 32.199999999999996, "seed": 1485551573, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Server", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Server", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 333, "versionNonce": 448586907, "isDeleted": false, "id": "_A8uznhnpXuQBYzjP-iVx", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 649.8080506852966, "y": 427.60908869342575, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 136, "height": 60, "seed": 1783625013, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "r90dckf8trHemYzEwCgCW" }, { "id": "XxfJWnHonmvNOJzMFSlie", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 298, "versionNonce": 1244067771, "isDeleted": false, "id": "r90dckf8trHemYzEwCgCW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 703.8080506852966, "y": 441.5090886934257, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 28, "height": 32.199999999999996, "seed": 660965013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113383, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "UI", "textAlign": "center", "verticalAlign": "middle", "containerId": "_A8uznhnpXuQBYzjP-iVx", "originalText": "UI", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 105, "versionNonce": 265992667, "isDeleted": false, "id": "v2eEwSOSRQBZ79O6wyzGf", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 800.9240766836483, "y": 421.4987043996123, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 135.3671503686619, "height": 62.2689029398432, "seed": 1115810805, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "svsVhxCbatcLj7lQLch0P" }, { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 83, "versionNonce": 1706870395, "isDeleted": false, "id": "svsVhxCbatcLj7lQLch0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 828.1594096804793, "y": 436.53315586953386, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 80.896484375, "height": 32.199999999999996, "seed": 2074781013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "GRPC", "textAlign": "center", "verticalAlign": "middle", "containerId": "v2eEwSOSRQBZ79O6wyzGf", "originalText": "GRPC", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 270, "versionNonce": 418660123, "isDeleted": false, "id": "hSrrwwnm9y7R-_CnJtaK1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.567103519039, "y": 556.4146894573112, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 1983197877, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 154, "versionNonce": 871605179, "isDeleted": false, "id": "8tsYgVssKnBd_Zw1QuqNz", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1298.4367898442752, "y": 566.567242947784, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 96.5234375, "height": 32.199999999999996, "seed": 1321669653, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent 1", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent 1", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 182, "versionNonce": 1323136091, "isDeleted": false, "id": "eeugZg73_yD_6uLBBgmcX", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 404.5001910129067, "y": 707.1233710221009, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 210.068359375, "height": 32.199999999999996, "seed": 1901447541, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "User => Browser", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "User => Browser", "lineHeight": 1.15, "baseline": 25 }, { "type": "ellipse", "version": 106, "versionNonce": 1501835515, "isDeleted": false, "id": "mlDhl4OOc-H1tNgh77AAW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 482.5857164810477, "y": 602.4394551739279, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 46.024748503793035, "height": 44.21988070606176, "seed": 791073493, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "line", "version": 166, "versionNonce": 627726747, "isDeleted": false, "id": "ADEXzdYAhvj-_wVRftTIg", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 459.12202200277807, "y": 697.1964604319912, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 80.31792517362464, "height": 31.585599568061298, "seed": 349155381, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": null, "points": [ [ 0, 0 ], [ 42.415150610916044, -28.87829787146393 ], [ 80.31792517362464, 2.7073016965973693 ] ] }, { "type": "rectangle", "version": 231, "versionNonce": 801271355, "isDeleted": false, "id": "xmz4J-rxLIjfUQ4q19PjD", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 516.8788931508789, "y": 870.4664542146543, "strokeColor": "#f08c00", "backgroundColor": "#fff4e6", "width": 385.34512717560705, "height": 60.464035142111264, "seed": 3531157, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 93, "versionNonce": 728690395, "isDeleted": false, "id": "gSbpry_947XArfI7b6AAL", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 636.1468430141358, "y": 878.5884970070326, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 132.2890625, "height": 32.199999999999996, "seed": 1989076725, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Autoscaler", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Autoscaler", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 118, "versionNonce": 1258445691, "isDeleted": false, "id": "WVy0mdTGbUx08RuxdQUH8", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 523.3741602213286, "y": 907.372811672524, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 369.1484375, "height": 18.4, "seed": 979386453, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Starts agents based on amount of pending pipelines", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Starts agents based on amount of pending pipelines", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 373, "versionNonce": 1254044699, "isDeleted": false, "id": "0Y1RcqzVFBFqh-wy-APMI", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1232.1955835481922, "y": 605.8737363119278, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 292.6171875, "height": 18.4, "seed": 561999285, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Executes pending workflows of a pipeline", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Executes pending workflows of a pipeline", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 630, "versionNonce": 983038139, "isDeleted": false, "id": "lGumbhMs3xx1vU2632hli", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 505.62283787078286, "y": 383.42044095379515, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 158.015625, "height": 36.8, "seed": 722595605, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Central unit of a \nWoodpecker instance ", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Central unit of a \nWoodpecker instance ", "lineHeight": 1.15, "baseline": 32 }, { "type": "rectangle", "version": 131, "versionNonce": 137308507, "isDeleted": false, "id": "PbSQXehWVLYcQGXYFpd-B", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 971.7123256059622, "y": 171.06951064323448, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 274.3443117379593, "height": 74.90311522655017, "seed": 1435321461, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 96, "versionNonce": 1222067707, "isDeleted": false, "id": "2P2tz29C_2sUzVNSpaG17", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.5206131439782, "y": 183.12082907329545, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 73.14453125, "height": 32.199999999999996, "seed": 884403669, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Forge", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Forge", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 141, "versionNonce": 1133694619, "isDeleted": false, "id": "0eYhFYPuRanZ7wkR2OlHO", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 986.864582863368, "y": 225.1223531590797, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 247.234375, "height": 18.4, "seed": 1201957685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "HK1jmIcPmM6Us6Jrynobb", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Github, Gitea, Github, Bitbucket, ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Github, Gitea, Github, Bitbucket, ...", "lineHeight": 1.15, "baseline": 14 }, { "type": "rectangle", "version": 55, "versionNonce": 991183675, "isDeleted": false, "id": "dihpRzuIc-UoRSsOI33SZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 820.419424341303, "y": 340.29123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 247151765, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "bcUL-u4zkLA9CLG2YdaeN" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 38, "versionNonce": 2008949723, "isDeleted": false, "id": "bcUL-u4zkLA9CLG2YdaeN", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 831.853994653803, "y": 358.79123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 94.130859375, "height": 23, "seed": 1638982133, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Webhooks", "textAlign": "center", "verticalAlign": "middle", "containerId": "dihpRzuIc-UoRSsOI33SZ", "originalText": "Webhooks", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 93, "versionNonce": 295891067, "isDeleted": false, "id": "Bphhue86mMXHN4klGamM3", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 697.3018309300141, "y": 339.607928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 92986197, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "0YxY2hEPyDWFqR8_-f6bn" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 87, "versionNonce": 2055547163, "isDeleted": false, "id": "0YxY2hEPyDWFqR8_-f6bn", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 727.4522215550141, "y": 358.107928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 56.69921875, "height": 23, "seed": 43952309, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "OAuth", "textAlign": "center", "verticalAlign": "middle", "containerId": "Bphhue86mMXHN4klGamM3", "originalText": "OAuth", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 284, "versionNonce": 1205292475, "isDeleted": false, "id": "HK1jmIcPmM6Us6Jrynobb", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1205.6453201409104, "y": 250.4849674923464, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 272.1094712799886, "height": 94.31865813977868, "seed": 982632981, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "uDIWJ5K5mEBL9QaiNk3cS" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "0eYhFYPuRanZ7wkR2OlHO", "focus": -0.8418551162334328, "gap": 6.962614333266799 }, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ -69.68740859223726, 65.87860410965993 ], [ -272.1094712799886, 94.31865813977868 ] ] }, { "type": "text", "version": 53, "versionNonce": 1803962459, "isDeleted": false, "id": "uDIWJ5K5mEBL9QaiNk3cS", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1050.575099048673, "y": 297.96357160200637, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 170.765625, "height": 36.8, "seed": 1046069109, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "sends events like push, \ntag, ...", "textAlign": "center", "verticalAlign": "middle", "containerId": "HK1jmIcPmM6Us6Jrynobb", "originalText": "sends events like push, tag, ...", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 487, "versionNonce": 335895291, "isDeleted": false, "id": "Kqbwk_qfkALJfhtCIr2eS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 792.0835609101814, "y": 316.38601649373913, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 176.92139414789008, "height": 122.73778943055902, "seed": 1681656021, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yvJTQ64RU50N6-hxEQlkl" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "UczUX5VuNnCB1rVvUJVfm", "focus": -0.03867359238356983, "gap": 4.489845092359474 }, "endBinding": { "elementId": "PbSQXehWVLYcQGXYFpd-B", "focus": 0.7798878042817562, "gap": 2.707370547890605 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 60.422360349016344, -71.97786730696657 ], [ 176.92139414789008, -122.73778943055902 ] ] }, { "type": "text", "version": 62, "versionNonce": 301106427, "isDeleted": false, "id": "yvJTQ64RU50N6-hxEQlkl", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 773.7910775091977, "y": 226.00814918677256, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 157.4296875, "height": 36.8, "seed": 500049461, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "allows users to login \nusing existing account", "textAlign": "center", "verticalAlign": "middle", "containerId": "Kqbwk_qfkALJfhtCIr2eS", "originalText": "allows users to login using existing account", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 393, "versionNonce": 598459861, "isDeleted": false, "id": "TvtonmlV0W8__pnTG-wVZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 936.9267543177084, "y": 458.95033086418084, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 215.17788326846676, "height": 93.99151368376693, "seed": 234198933, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "rFf6NIofw6UBOyAFwg0Kn" } ], "updated": 1697530127259, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.30339107267010673, "gap": 1 }, "endBinding": { "elementId": "hSrrwwnm9y7R-_CnJtaK1", "focus": -0.14057158065513534, "gap": 3.4728449093634026 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 130.0760301643047, 42.90930518030268 ], [ 215.17788326846676, 93.99151368376693 ] ] }, { "type": "text", "version": 8, "versionNonce": 1693330843, "isDeleted": false, "id": "rFf6NIofw6UBOyAFwg0Kn", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 997.4942845557462, "y": 473.9409015069133, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 1592253685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "TvtonmlV0W8__pnTG-wVZ", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 270, "versionNonce": 1855882619, "isDeleted": false, "id": "5tl702dfcvJDLz9aIFU0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 886.0581619083632, "y": 485.67004123832135, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 174.09447592006472, "height": 326.4905563076211, "seed": 1479177813, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "apyMCAv2GIN_yzHXwX4tY" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.1341191028023529, "gap": 1.9024338988657519 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "focus": -0.7088661407505865, "gap": 4.060573862784622 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 44.14165353942735, 196.18483635907205 ], [ 174.09447592006472, 326.4905563076211 ] ] }, { "type": "text", "version": 66, "versionNonce": 2007745083, "isDeleted": false, "id": "apyMCAv2GIN_yzHXwX4tY", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 849.4927841977906, "y": 663.4548775973934, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 882041781, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "5tl702dfcvJDLz9aIFU0P", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 347, "versionNonce": 1353818811, "isDeleted": false, "id": "XxfJWnHonmvNOJzMFSlie", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 534.9278465333664, "y": 595.2199151317081, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 113.88020415193023, "height": 119.81968366814112, "seed": 944153877, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "_A8uznhnpXuQBYzjP-iVx", "focus": 0.5397285671082249, "gap": 1 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 113.88020415193023, -119.81968366814112 ] ] }, { "type": "rectangle", "version": 61, "versionNonce": 1099141979, "isDeleted": false, "id": "j56ZKRwmXk72nHrZzLz_1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1081.8110514012087, "y": 652.5253283508498, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 566.7373014532342, "height": 68.58600908319681, "seed": 112933493, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 82, "versionNonce": 1879994363, "isDeleted": false, "id": "cAVYXfBRnfuGAv7QTQVow", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1300.6584159706863, "y": 658.8425033454967, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 77.83203125, "height": 23, "seed": 951460821, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Backend", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Backend", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 376,- add some images explaining the architecture & terminology with pipeline -> workflow -> step - combine advanced config usage - rename pipeline syntax to workflow syntax (and most references to pipeline steps etc as well) - update agent registration part - add bug note to secrets encryption setting - remove usage from readme to point to up-to-date docs page - typos - closes #1408 --------- "angle": 0, "x": 1094.1972977313717, "y": 681.8988272758752, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 530.9453125, "height": 55.199999999999996, "seed": 843899189, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "lineHeight": 1.15, "baseline": 50 }, { "type": "rectangle", "version": 384, "versionNonce": 1778969915, "isDeleted": false, "id": "pxF49EKDNO6IZq_34i7bY", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1064.2132116912126, "y": 754.5018564383092, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 954528405, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "arrow", "version": 154, "versionNonce": 1988988379, "isDeleted": false, "id": "05EJzh4NLXxemaKAmdi5n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 904.0288881242177, "y": 882.4966027880746, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 158.83070714434325, "height": 32.735025983189644, "seed": 1228134389, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yNxAOEPZu_Jl7mnI01OXs" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "xmz4J-rxLIjfUQ4q19PjD", "gap": 1.8048677977312764, "focus": 0.31250963573550006 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "gap": 1.353616422651612, "focus": 0.36496042109885213 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 158.83070714434325, -32.735025983189644 ] ] }, { "type": "text", "version": 25, "versionNonce": 1393410779, "isDeleted": false, "id": "yNxAOEPZu_Jl7mnI01OXs", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 963.8856479463893, "y": 856.9290897964797, "strokeColor": "#f08c00", "backgroundColor": "#b2f2bb", "width": 39.1171875, "height": 18.4, "seed": 759107925, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113387, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "starts", "textAlign": "center", "verticalAlign": "middle", "containerId": "05EJzh4NLXxemaKAmdi5n", "originalText": "starts", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 187, "versionNonce": 671224603, "isDeleted": false, "id": "sSj4Pda-fo-BBYM_dzml6", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1296.0854928322988, "y": 776.6118140041631, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 104.2890625, "height": 32.199999999999996, "seed": 1381768885, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent ...", "lineHeight": 1.15, "baseline": 25 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/15-terminology/index.md ================================================ # Terminology ## Glossary - **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC. - **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines. - **Code**: Refers to the files tracked by the version control system used by the [forge][Forge]. - **Commit**: A defined state of the code, usually associated with a version control system like Git. - **Container**: A lightweight and isolated environment where commands are executed. - **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel. - **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI. - **[Forge][Forge]**: The hosting platform or service where the repositories are hosted. - **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix. - **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events. - **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings. - **Repos**: Short for repositories, these are storage locations where code is stored. - **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration. - **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow]. - **Service extension**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through service extensions. - **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge]. - **Steps**: Individual commands, actions or tasks within a [workflow][Workflow]. - **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue. - **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code. - **Woodpecker CI**: The project name around Woodpecker. - **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker). - **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps. - **YAML File**: A file format used to define and configure [workflows][Workflow]. ## Woodpecker architecture ![Woodpecker architecture](architecture.svg) ## Pipeline, workflow & step ![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg) ## Conventions Sometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker: - Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()` - Use the term **pipelines** instead of the previous **builds** - Use the term **steps** instead of the previous **jobs** - Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users [Event]: ../20-workflow-syntax.md#event [Pipeline]: ../20-workflow-syntax.md [Workflow]: ../25-workflows.md [Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md [Plugin]: ../51-plugins/51-overview.md [Workspace]: ../20-workflow-syntax.md#workspace [Matrix]: ../30-matrix-workflows.md [Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md [Local]: ../../30-administration/10-configuration/11-backends/30-local.md ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/15-terminology/pipeline-workflow-step.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 97, "versionNonce": 257762037, "isDeleted": false, "id": "Y3hYdpX9r1qWfyHWs7AXT", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 393.622323134362, "y": 336.02197155458475, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 366.3936710429598, "height": 499.95605689083004, "seed": 875444373, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 67, "versionNonce": 369556565, "isDeleted": false, "id": "g1Eb010Kx_KFryVqNYWBQ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 520.0116988873679, "y": 363.32095846456355, "strokeColor": "#1971c2", "backgroundColor": "#b2f2bb", "width": 99.626953125, "height": 32.199999999999996, "seed": 1466195445, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Pipeline", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Pipeline", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 314, "versionNonce": 1983028731, "isDeleted": false, "id": "9o-DNP0YdlIGVz1kEm_hW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 407.1590381712276, "y": 410.9252244837219, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1869535061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" }, { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083624, "link": null, "locked": false }, { "type": "rectangle", "version": 156, "versionNonce": 1495247317, "isDeleted": false, "id": "q4TKpiq2KAwPaz19GdhtK", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 490.3194993196821, "y": 473.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 111355061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "ya0JzDo-4oscHIq87TZ_D" }, { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" }, { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 156, "versionNonce": 1469425461, "isDeleted": false, "id": "ya0JzDo-4oscHIq87TZ_D", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 566.0118821321821, "y": 478.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1084671509, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "q4TKpiq2KAwPaz19GdhtK", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 236, "versionNonce": 1535319541, "isDeleted": false, "id": "AOJLQFldoHd2vxVtB2jrS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 491.2218643672577, "y": 519.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 812596085, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "FRby8A9aUiKvHpM5mCdDN" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 231, "versionNonce": 28677973, "isDeleted": false, "id": "FRby8A9aUiKvHpM5mCdDN", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 583.0324112422577, "y": 524.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1849820373, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "AOJLQFldoHd2vxVtB2jrS", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 291, "versionNonce": 571598005, "isDeleted": false, "id": "2WwuMWX7YawqK0i1rDPJo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 489.6426911083554, "y": 567.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1840554549, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "UOwxmKIS0W62CFt_ffEy4" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 289, "versionNonce": 4032021, "isDeleted": false, "id": "UOwxmKIS0W62CFt_ffEy4", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 581.4532379833554, "y": 572.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 330077077, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "2WwuMWX7YawqK0i1rDPJo", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 296, "versionNonce": 1539516059, "isDeleted": false, "id": "9laL3864YWOna6NQlVDqq", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 630.0635849044402, "y": 383.14314287821776, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 294.3024370154917, "height": 36.656016722015465, "seed": 207575285, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -1.000156025347643, "gap": 27.782081605504118 }, "endBinding": { "elementId": "vS2PNUbmeBe3EPxl-dID8", "focus": 0.7761987167055517, "gap": 8.978940924346716 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 294.3024370154917, -36.656016722015465 ] ] }, { "type": "text", "version": 249, "versionNonce": 2076402229, "isDeleted": false, "id": "vS2PNUbmeBe3EPxl-dID8", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 933.3449628442786, "y": 336.02200598023114, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 301.298828125, "height": 46, "seed": 1632793173, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "A pipeline is triggered by an event\nlike a push, tag, manual", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "A pipeline is triggered by an event\nlike a push, tag, manual", "lineHeight": 1.15, "baseline": 41 }, { "type": "arrow", "version": 751, "versionNonce": 1371044827, "isDeleted": false, "id": "FU4jk6Tz6duLaaZE0Z55A", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 751.1619011845514, "y": 440.8355079324799, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 160.46519124360202, "height": 2.2452348338335923, "seed": 1331388341, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -0.6591700594229558, "gap": 3.8807513696519322 }, "endBinding": { "elementId": "wfFvnFZuh0npL9hh0ez7o", "focus": 0.7652411053273549, "gap": 20.75618622779257 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 160.46519124360202, -2.2452348338335923 ] ] }, { "type": "rectangle", "version": 440, "versionNonce": 819540565, "isDeleted": false, "id": "TbejdIYo_qNDw15yLP2IB", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 406.0812257713851, "y": 626.8305540252475, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1553965333, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 466, "versionNonce": 663477, "isDeleted": false, "id": "wfFvnFZuh0npL9hh0ez7o", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 932.383278655946, "y": 424.0107569968011, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 481.2890625, "height": 115, "seed": 781497973, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "lineHeight": 1.15, "baseline": 110 }, { "type": "arrow", "version": 464, "versionNonce": 734626075, "isDeleted": false, "id": "1ZbDRqbETCkEx62nCmnpJ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 741.0645380446722, "y": 492.31283255558515, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 178.4459423531871, "height": 83.08707392565111, "seed": 536879061, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "q4TKpiq2KAwPaz19GdhtK", "focus": -0.7697471991854113, "gap": 3.7450387249900814 }, "endBinding": { "elementId": "Vu0JJ6ZWuEhEyCfxeHPtc", "focus": -0.7822252364700005, "gap": 8.360835317635974 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 178.4459423531871, 83.08707392565111 ] ] }, { "type": "text", "version": 327, "versionNonce": 371646421, "isDeleted": false, "id": "Vu0JJ6ZWuEhEyCfxeHPtc", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 927.8713157154953, "y": 563.2132686484658, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 491.357421875, "height": 46, "seed": 385310005, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "lineHeight": 1.15, "baseline": 41 }, { "type": "text", "version": 91, "versionNonce": 1180085909, "isDeleted": false, "id": "0tGx2VdJLNf7W6HD76dtO", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 427.6895298601876, "y": 432.3583566254258, "strokeColor": "#9c36b5", "backgroundColor": "#a5d8ff", "width": 143.876953125, "height": 23, "seed": 450883221, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"build\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"build\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 338, "versionNonce": 957223925, "isDeleted": false, "id": "LQ2h2aO9uzDWyLG6OLn70", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.7251825950889, "y": 685.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 711939061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "8EqaPnZX2CgLaF08UNZZg" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 340, "versionNonce": 510774613, "isDeleted": false, "id": "8EqaPnZX2CgLaF08UNZZg", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 563.4175654075889, "y": 690.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1370164565, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "LQ2h2aO9uzDWyLG6OLn70", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 421, "versionNonce": 97999541, "isDeleted": false, "id": "St9t4nwHuXXVlmjDqfn_Z", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 488.62754764266447, "y": 731.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 2145950389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "DX10t075MMDu7BLtuUaij" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 417, "versionNonce": 2011446293, "isDeleted": false, "id": "DX10t075MMDu7BLtuUaij", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 580.4380945176645, "y": 736.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 500005909, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "St9t4nwHuXXVlmjDqfn_Z", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 475, "versionNonce": 1284370805, "isDeleted": false, "id": "XVGBz_X5yN6xjWTosVH2n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.04837438376217, "y": 779.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1666134389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "-xogFSFcP-Vv5cuOSFm8T" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 476, "versionNonce": 1092221653, "isDeleted": false, "id": "-xogFSFcP-Vv5cuOSFm8T", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 578.8589212587622, "y": 784.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1840462549, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "XVGBz_X5yN6xjWTosVH2n", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 125, "versionNonce": 1310578741, "isDeleted": false, "id": "N1a9yL7Pts16hUKY9-vhw", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 424.78852030984035, "y": 646.2446482189896, "strokeColor": "#be4bdb", "backgroundColor": "#a5d8ff", "width": 133.857421875, "height": 23, "seed": 361699381, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"test\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"test\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 184, "versionNonce": 2127603131, "isDeleted": false, "id": "O-YmtRLb8uFNqCAz22EoG", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 737.454940151797, "y": 535.9141784615474, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 190.41665096887027, "height": 112.96427727851824, "seed": 80234901, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.8392895251910331, "gap": 2.0300115262207328 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 190.41665096887027, 112.96427727851824 ] ] }, { "type": "arrow", "version": 327, "versionNonce": 780710651, "isDeleted": false, "id": "379hO6Dc5rygB38JgDbVo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 738.8084877231549, "y": 591.3526691276127, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 186.8066399682357, "height": 57.68023784868956, "seed": 211046133, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "2WwuMWX7YawqK0i1rDPJo", "focus": -0.5776522830934517, "gap": 2.1657966147995467 }, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.7269489945238884, "gap": 4.286474955497397 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 186.8066399682357, 57.68023784868956 ] ] }, { "type": "text", "version": 285, "versionNonce": 1165977685, "isDeleted": false, "id": "0TjxOfERekC91N3yciQIq", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 929.901602646888, "y": 632.4760859429873, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 518.076171875, "height": 46, "seed": 997763157, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "O-YmtRLb8uFNqCAz22EoG", "type": "arrow" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "lineHeight": 1.15, "baseline": 41 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/20-workflow-syntax.md ================================================ # Workflow syntax The Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status. :::note An exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run. ::: :::note We support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility. Read more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3) ::: Example steps: ```yaml steps: - name: backend image: golang commands: - go build - go test - name: frontend image: node commands: - npm install - npm run test - npm run build ``` In the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary. The name is optional, if not added the steps will be numerated. Another way to name a step is by using dictionaries: ```yaml steps: backend: image: golang commands: - go build - go test frontend: image: node commands: - npm install - npm run test - npm run build ``` ## Skip Commits Woodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive. ```bash git commit -m "updated README [CI SKIP]" ``` ## Steps Every step of your workflow executes commands inside a specified container.
The defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).
The associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` ### File changes are incremental - Woodpecker clones the source code in the beginning of the workflow - Changes to files are persisted through steps as the same volume is mounted to all steps ```yaml title=".woodpecker.yaml" steps: - name: build image: debian commands: - echo "test content" > myfile - name: a-test-step image: debian commands: - cat myfile ``` ### `image` Woodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers. When using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands. ```diff steps: - name: build + image: golang:1.6 commands: - go build - go test - name: prettier + image: woodpeckerci/plugin-prettier services: - name: database + image: mysql ``` Woodpecker supports any valid Docker image from any Docker registry: ```yaml image: golang image: golang:1.7 image: library/golang:1.7 image: index.docker.io/library/golang image: index.docker.io/library/golang:1.7 ``` Learn more how you can use images from [different registries](./41-registries.md). ### `pull` By default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present. To always pull the latest image when updates are available, use the `pull` option: ```diff steps: - name: build image: golang:latest + pull: true ``` ### `commands` Commands of every step are executed serially as if you would enter them into your local shell. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` There is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script: ```bash #!/bin/sh set -e go build go test ``` The above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed: ```bash docker run --entrypoint=build.sh golang ``` :::note Only build steps can define commands. You cannot use commands with plugins or services. ::: ### `entrypoint` Allows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `["/bin/sh", "-c"]`). If you define [`commands`](#commands), the default entrypoint will be `["/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"]`. You can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`. ### `environment` Woodpecker provides the ability to pass environment variables to individual steps. For more details, check the [environment docs](./50-environment.md). ### `failure` Some of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow. ```diff steps: - name: backend image: golang commands: - go build - go test + failure: ignore ``` ### `when` - Conditional Execution Woodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true. A condition can be a check like: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - event: pull_request + repo: test/test + - event: push + branch: main ``` The `prettier` step is executed if one of these conditions is met: 1. The pipeline is executed from a pull request in the repo `test/test` 2. The pipeline is executed from a push to `main` #### `repo` Example conditional execution by repository: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - repo: test/test ``` #### `branch` :::note Branch conditions are not applied to tags. ::: Example conditional execution by branch: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - branch: main ``` > The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only. Execute a step if the branch is `main` or `develop`: ```yaml when: - branch: [main, develop] ``` Execute a step if the branch starts with `prefix/*`: ```yaml when: - branch: prefix/* ``` The branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples: - `*\\/*` to match patterns with exactly 1 `/` - `*\\/**` to match patters with at least 1 `/` - `*` to match patterns without `/` - `**` to match everything Execute a step using custom include and exclude logic: ```yaml when: - branch: include: [main, release/*] exclude: [release/1.0.0, release/1.1.*] ``` #### `event` The available events are: - `push`: triggered when a commit is pushed to a branch. - `pull_request`: triggered when a pull request is opened or a new commit is pushed to it. - `pull_request_closed`: triggered when a pull request is closed or merged. - `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...). - `tag`: triggered when a tag is pushed. - `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).) - `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.) - `cron`: triggered when a cron job is executed. - `manual`: triggered when a user manually triggers a pipeline. Execute a step if the build event is a `tag`: ```yaml when: - event: tag ``` Execute a step if the pipeline event is a `push` to a specified branch: ```diff when: - event: push + branch: main ``` Execute a step for multiple events: ```yaml when: - event: [push, tag, deployment] ``` #### `cron` This filter **only** applies to cron events and filters based on the name of a cron job. Make sure to have a `event: cron` condition in the `when`-filters as well. ```yaml when: - event: cron cron: sync_* # name of your cron job ``` [Read more about cron](./45-cron.md) #### `ref` The `ref` filter compares the git reference against which the workflow is executed. This allows you to filter, for example, tags that must start with **v**: ```yaml when: - event: tag ref: refs/tags/v* ``` #### `status` There are use cases for executing steps on failure, such as sending notifications for failed workflow/pipeline. Use the status constraint to execute steps even when the workflow fails: ```diff steps: - name: notify image: alpine + when: + - status: [ success, failure ] ``` #### `platform` :::note This condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch. ::: Execute a step for a specific platform: ```yaml when: - platform: linux/amd64 ``` Execute a step for a specific platform using wildcards: ```yaml when: - platform: [linux/*, windows/amd64] ``` #### `matrix` Execute a step for a single matrix permutation: ```yaml when: - matrix: GO_VERSION: 1.5 REDIS_VERSION: 2.8 ``` #### `instance` Execute a step only on a certain Woodpecker instance matching the specified hostname: ```yaml when: - instance: stage.woodpecker.company.com ``` #### `path` :::info Path conditions are applied only to **push** and **pull_request** events. ::: Execute a step only on a pipeline with certain files being changed: ```yaml when: - path: 'src/*' ``` You can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`. For pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases. ```yaml when: - path: include: ['.woodpecker/*.yaml', '*.ini'] exclude: ['*.md', 'docs/**'] ignore_message: '[ALL]' on_empty: true ``` :::info Passing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting. ::: #### `evaluate` Execute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression. The expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library. Run on pushes to the default branch for the repository `owner/repo`: ```yaml when: - evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' ``` Run on commits created by user `woodpecker-ci`: ```yaml when: - evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' ``` Skip all commits containing `please ignore me` in the commit message: ```yaml when: - evaluate: 'not (CI_COMMIT_MESSAGE contains "please ignore me")' ``` Run on pull requests with the label `deploy`: ```yaml when: - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "deploy"' ``` Skip step only if `SKIP=true`, run otherwise or if undefined: ```yaml when: - evaluate: 'SKIP != "true"' ``` ### `depends_on` Normally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`: ```diff steps: - name: build # build will be executed immediately image: golang commands: - go build - name: deploy image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file + depends_on: [build, test] # deploy will be executed after build and test finished - name: test # test will be executed immediately as no dependencies are set image: golang commands: - go test ``` :::note You can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified. ```yaml steps: - name: check code format image: mstruebing/editorconfig-checker depends_on: [] # enable parallel steps ... ``` ::: ### `volumes` Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. For more details check the [volumes docs](./70-volumes.md). ### `detach` Woodpecker gives the ability to detach steps to run them in background until the workflow finishes. For more details check the [service docs](./60-services.md#detachment). ### `directory` Using `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run. ### `backend_options` With `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes. Further details can be found in the documentation of the used backend: - [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration) - [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration) ## `services` Woodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow. For more details check the [services docs](./60-services.md). ## `workspace` The workspace defines the shared volume and working directory shared by all workflow steps. The default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`). So an example would be `/woodpecker/src/github.com/octocat/hello-world`. The workspace can be customized using the workspace block in the YAML file: ```diff +workspace: + base: /go + path: src/github.com/octocat/hello-world steps: - name: build image: golang:latest commands: - go get - go test ``` :::note Plugins will always have the workspace base at `/woodpecker` ::: The base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps. ```diff workspace: + base: /go path: src/github.com/octocat/hello-world steps: - name: deps image: golang:latest commands: - go get - go test - name: build image: node:latest commands: - go build ``` This would be equivalent to the following docker commands: ```bash docker volume create my-named-volume docker run --volume=my-named-volume:/go golang:latest docker run --volume=my-named-volume:/go node:latest ``` The path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path. ```diff workspace: base: /go + path: src/github.com/octocat/hello-world ``` ```bash git clone https://github.com/octocat/hello-world \ /go/src/github.com/octocat/hello-world ``` ## `matrix` Woodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations. For more details check the [matrix build docs](./30-matrix-workflows.md). ## `labels` You can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent. To specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo. Workflow labels with an empty value are ignored. By default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`. :::warning Labels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition. ::: You can add additional labels as a key value map: ```diff +labels: + location: europe # only agents with `location=europe` or `location=*` will be used + weather: sun + hostname: "" # this label will be ignored as it is empty steps: - name: build image: golang commands: - go build - go test ``` ### Filter by platform To configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key. Have a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`. Example: Assuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`. ```diff +labels: + platform: linux/arm64 steps: [...] ``` ## `variables` Woodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration. For more details and examples check the [Advanced usage docs](./90-advanced-usage.md) ## `clone` Woodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step. You can manually configure the clone step in your workflow to customize it: ```diff +clone: + git: + image: woodpeckerci/plugin-git steps: - name: build image: golang commands: - go build - go test ``` Example configuration to override the depth: ```diff clone: - name: git image: woodpeckerci/plugin-git + settings: + partial: false + depth: 50 ``` Example configuration to use a custom clone plugin: ```diff clone: - name: git + image: octocat/custom-git-plugin ``` ### Git Submodules To use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`: ```diff [submodule "my-module"] path = my-module -url = git@github.com:octocat/my-module.git +url = https://github.com/octocat/my-module.git ``` To use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`: ```diff clone: - name: git image: woodpeckerci/plugin-git settings: recursive: true + submodule_override: + my-module: https://github.com/octocat/my-module.git steps: ... ``` ## `skip_clone` :::warning The default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`. ::: By default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using: ```yaml skip_clone: true ``` ## `when` - Global workflow conditions Woodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue. For more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution). Example conditional execution by branch: ```diff +when: + branch: main + steps: - name: prettier image: woodpeckerci/plugin-prettier ``` The workflow now triggers on `main`, but also if the target branch of a pull request is `main`. ## `depends_on` Woodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword. ## `runs_on` Workflows that should run even on failure should set the `runs_on` tag. See [here](./25-workflows.md#flow-control) for an example. ## Advanced network options for steps :::warning Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. ::: ### `dns` If the backend engine understands to change the DNS server and lookup domain, this options will be used to alter the default DNS config to a custom one for a specific step. ```yaml steps: - name: build image: plugin/abc dns: 1.2.3.4 dns_search: 'internal.company' ``` ## Privileged mode Woodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities. :::info Privileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker environment: - DOCKER_HOST=tcp://docker:2375 commands: - docker --tls=false ps services: - name: docker image: docker:dind commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false + privileged: true ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/25-workflows.md ================================================ # Workflows A pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps. In case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow. By placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored. You can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md). ## Benefits of using workflows - faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote - better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying - utilizing more agents to speed up the execution of the whole pipeline ## Example workflow definition :::warning Please note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow. If you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket). ::: ```bash .woodpecker/ ├── build.yaml ├── deploy.yaml ├── lint.yaml └── test.yaml ``` ```yaml title=".woodpecker/build.yaml" steps: - name: build image: debian:stable-slim commands: - echo building - sleep 5 ``` ```yaml title=".woodpecker/deploy.yaml" steps: - name: deploy image: debian:stable-slim commands: - echo deploying depends_on: - lint - build - test ``` ```yaml title=".woodpecker/test.yaml" steps: - name: test image: debian:stable-slim commands: - echo testing - sleep 5 depends_on: - build ``` ```yaml title=".woodpecker/lint.yaml" steps: - name: lint image: debian:stable-slim commands: - echo linting - sleep 5 ``` ## Status lines Each workflow will report its own status back to your forge. ## Flow control The workflows run in parallel on separate agents and share nothing. Dependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully. The name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`. ```diff steps: - name: deploy image: debian:stable-slim commands: - echo deploying +depends_on: + - lint + - build + - test ``` Workflows that need to run even on failures should set the `runs_on` tag. ```diff steps: - name: notify image: debian:stable-slim commands: - echo notifying depends_on: - deploy +runs_on: [ success, failure ] ``` :::info Some workflows don't need the source code, like creating a notification on failure. Read more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone) ::: ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/30-matrix-workflows.md ================================================ # Matrix workflows Woodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations. :::warning Woodpecker currently supports a maximum of **27 matrix axes** per workflow. If your matrix exceeds this number, any additional axes will be silently ignored. ::: Example matrix definition: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 REDIS_VERSION: - 2.6 - 2.8 - 3.0 ``` Example matrix definition containing only specific combinations: ```yaml matrix: include: - GO_VERSION: 1.4 REDIS_VERSION: 2.8 - GO_VERSION: 1.5 REDIS_VERSION: 2.8 - GO_VERSION: 1.6 REDIS_VERSION: 3.0 ``` ## Interpolation Matrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 DATABASE: - mysql:8 - mysql:5 - mariadb:10.1 steps: - name: build image: golang:${GO_VERSION} commands: - go get - go build - go test services: - name: database image: ${DATABASE} ``` Example YAML file after injecting the matrix parameters: ```diff steps: - name: build - image: golang:${GO_VERSION} + image: golang:1.4 commands: - go get - go build - go test + environment: + - GO_VERSION=1.4 + - DATABASE=mysql:8 services: - name: database - image: ${DATABASE} + image: mysql:8 ``` ## Examples ### Example matrix pipeline based on Docker image tag ```yaml matrix: TAG: - 1.7 - 1.8 - latest steps: - name: build image: golang:${TAG} commands: - go build - go test ``` ### Example matrix pipeline based on container image ```yaml matrix: IMAGE: - golang:1.7 - golang:1.8 - golang:latest steps: - name: build image: ${IMAGE} commands: - go build - go test ``` ### Example matrix pipeline using multiple platforms ```yaml matrix: platform: - linux/amd64 - linux/arm64 labels: platform: ${platform} steps: - name: test image: alpine commands: - echo "I am running on ${platform}" - name: test-arm-only image: alpine commands: - echo "I am running on ${platform}" - echo "Arm is cool!" when: platform: linux/arm* ``` :::note If you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector). ::: ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/40-secrets.md ================================================ # Secrets Woodpecker provides the ability to store named variables in a central secret store. These secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`. There are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins): 1. **Repository secrets**: Available for all pipelines of a repository. 1. **Organization secrets**: Available for all pipelines of an organization. 1. **Global secrets**: Can only be set by instance administrators. Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution. In addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources. :::warning Woodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs. ::: ## Usage You can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax. The following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`: ```diff steps: - name: 'step name' image: registry/repo/image:tag commands: + - echo "The secret is $TOKEN_ENV" + environment: + TOKEN_ENV: + from_secret: secret_token ``` The same syntax can be used to pass secrets to (plugin) settings. A secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details). `PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution. ```diff steps: - name: 'step name' image: registry/repo/image:tag + settings: + TOKEN: + from_secret: secret_token ``` ### Escape secrets Please note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts. If secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing. ```diff steps: - name: docker image: docker commands: - - echo ${TOKEN_ENV} + - echo $${TOKEN_ENV} environment: TOKEN_ENV: from_secret: secret_token ``` ### Events filter By default, secrets are not exposed to pull requests. However, you can change this behavior by creating the secret and enabling the `pull_request` event type. This can be configured either via the UI or via the CLI. :::warning Be careful when exposing secrets for pull requests. If your repository is public and accepts pull requests from everyone, your secrets may be at risk. Malicious actors could take advantage of this to expose your secrets or transfer them to an external location. ::: ### Plugins filter To prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins. If enabled, they are not available to any other plugins. Plugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets. :::tip If you specify a tag, the filter will take it into account. However, if the same image appears several times in the list, the least privileged entry will take precedence. For example, an image without a tag will allow all tags, even if it contains another entry with a tag attached. ::: ![plugins filter](./secrets-plugins-filter.png) ## CLI In addition to the UI, secrets can also be managed using the CLI. Create the secret with the default settings. The secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events). ```bash woodpecker-cli repo secret add \ --repository octocat/hello-world \ --name aws_access_key_id \ --value ``` Create the secret and limit it to a single image: ```diff woodpecker-cli secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ --name aws_access_key_id \ --value ``` Create the secrets and limit it to a set of images: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ + --image woodpeckerci/plugin-docker-buildx \ --name aws_access_key_id \ --value ``` Create the secret and enable it for multiple hook events: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ --image woodpeckerci/plugin-s3 \ + --event pull_request \ + --event push \ + --event tag \ --name aws_access_key_id \ --value ``` Secrets can be loaded from a file using the syntax `@`. This method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example): ```diff woodpecker-cli repo secret add \ -repository octocat/hello-world \ -name ssh_key \ + -value @/root/ssh/id_rsa ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/41-registries.md ================================================ # Registries Woodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries. ## Images from private registries You must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file. These credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin. Example configuration using a private image: ```diff steps: - name: build + image: gcr.io/custom/golang commands: - go build - go test ``` Woodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers. Example registry hostnames: - Image `gcr.io/foo/bar` has hostname `gcr.io` - Image `foo/bar` has hostname `docker.io` - Image `qux.com:8000/foo/bar` has hostname `qux.com:8000` Example registry hostname matching logic: - Hostname `gcr.io` matches image `gcr.io/foo/bar` - Hostname `docker.io` matches `golang` - Hostname `docker.io` matches `library/golang` - Hostname `docker.io` matches `bradrydzewski/golang` - Hostname `docker.io` matches `bradrydzewski/golang:latest` ## Global registry support To make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config). ## GCR registry support For specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file). ## Local Images :::warning For this, privileged rights are needed only available to admins. In addition, this only works when using a single agent. ::: It's possible to build a local image by mounting the docker socket as a volume. With a `Dockerfile` at the root of the project: ```yaml steps: - name: build-image image: docker commands: - docker build --rm -t local/project-image . volumes: - /var/run/docker.sock:/var/run/docker.sock - name: build-project image: local/project-image commands: - ./build.sh ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/45-cron.md ================================================ # Cron To configure cron jobs you need at least push access to the repository. ## Add a new cron job 1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job: ```diff steps: - name: sync_locales image: weblate_sync settings: url: example.com token: from_secret: weblate_token + when: + event: cron + cron: "name of the cron job" # if you only want to execute this step by a specific cron job ``` 2. Create a new cron job in the repository settings: ![cron settings](./cron-settings.png) The supported schedule syntax can be found at . If you need general understanding of the cron syntax is a good place to start and experiment. Examples: `@every 5m`, `@daily`, `30 * * * *` ... ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/50-environment.md ================================================ # Environment variables Woodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables: ```diff steps: - name: build image: golang + environment: + CGO: 0 + GOOS: linux + GOARCH: amd64 commands: - go build - go test ``` Please note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section. ```diff steps: - name: build image: golang - environment: - - PATH=$PATH:/go commands: + - export PATH=$PATH:/go - go build - go test ``` :::warning `${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped: ::: ```diff steps: - name: build image: golang commands: - - export PATH=${PATH}:/go + - export PATH=$${PATH}:/go - go build - go test ``` ## Built-in environment variables This is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime. | NAME | Description | Example | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `CI` | CI environment name | `woodpecker` | | | **Repository** | | | `CI_REPO` | repository full name `/` | `john-doe/my-repo` | | `CI_REPO_OWNER` | repository owner | `john-doe` | | `CI_REPO_NAME` | repository name | `my-repo` | | `CI_REPO_REMOTE_ID` | repository remote ID, is the UID it has in the forge | `82` | | `CI_REPO_URL` | repository web URL | `https://git.example.com/john-doe/my-repo` | | `CI_REPO_CLONE_URL` | repository clone URL | `https://git.example.com/john-doe/my-repo.git` | | `CI_REPO_CLONE_SSH_URL` | repository SSH clone URL | `git@git.example.com:john-doe/my-repo.git` | | `CI_REPO_DEFAULT_BRANCH` | repository default branch | `main` | | `CI_REPO_PRIVATE` | repository is private | `true` | | `CI_REPO_TRUSTED_NETWORK` | repository has trusted network access | `false` | | `CI_REPO_TRUSTED_VOLUMES` | repository has trusted volumes access | `false` | | `CI_REPO_TRUSTED_SECURITY` | repository has trusted security access | `false` | | | **Current Commit** | | | `CI_COMMIT_SHA` | commit SHA | `eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_COMMIT_REF` | commit ref | `refs/heads/main` | | `CI_COMMIT_REFSPEC` | commit ref spec | `issue-branch:main` | | `CI_COMMIT_BRANCH` | commit branch (equals target branch for pull requests) | `main` | | `CI_COMMIT_SOURCE_BRANCH` | commit source branch (set only for pull request events) | `issue-branch` | | `CI_COMMIT_TARGET_BRANCH` | commit target branch (set only for pull request events) | `main` | | `CI_COMMIT_TAG` | commit tag name (empty if event is not `tag`) | `v1.10.3` | | `CI_COMMIT_PULL_REQUEST` | commit pull request number (set only for pull request events) | `1` | | `CI_COMMIT_PULL_REQUEST_LABELS` | labels assigned to pull request (set only for pull request events) | `server` | | `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events) | `summer-sprint` | | `CI_COMMIT_MESSAGE` | commit message | `Initial commit` | | `CI_COMMIT_AUTHOR` | commit author username | `john-doe` | | `CI_COMMIT_AUTHOR_EMAIL` | commit author email address | `john-doe@example.com` | | `CI_COMMIT_PRERELEASE` | release is a pre-release (empty if event is not `release`) | `false` | | | **Current pipeline** | | | `CI_PIPELINE_NUMBER` | pipeline number | `8` | | `CI_PIPELINE_PARENT` | number of parent pipeline | `0` | | `CI_PIPELINE_EVENT` | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PIPELINE_EVENT_REASON` | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PIPELINE_URL` | link to the web UI for the pipeline | `https://ci.example.com/repos/7/pipeline/8` | | `CI_PIPELINE_FORGE_URL` | link to the forge's web UI for the commit(s) or tag that triggered the pipeline | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_PIPELINE_DEPLOY_TARGET` | pipeline deploy target for `deployment` events | `production` | | `CI_PIPELINE_DEPLOY_TASK` | pipeline deploy task for `deployment` events | `migration` | | `CI_PIPELINE_CREATED` | pipeline created UNIX timestamp | `1722617519` | | `CI_PIPELINE_STARTED` | pipeline started UNIX timestamp | `1722617519` | | `CI_PIPELINE_FILES` | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[".woodpecker.yml","README.md"]` | | `CI_PIPELINE_AUTHOR` | pipeline author username | `octocat` | | `CI_PIPELINE_AVATAR` | pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | | **Current workflow** | | | `CI_WORKFLOW_NAME` | workflow name | `release` | | | **Current step** | | | `CI_STEP_NAME` | step name | `build package` | | `CI_STEP_NUMBER` | step number | `0` | | `CI_STEP_STARTED` | step started UNIX timestamp | `1722617519` | | `CI_STEP_URL` | URL to step in UI | `https://ci.example.com/repos/7/pipeline/8` | | | **Previous commit** | | | `CI_PREV_COMMIT_SHA` | previous commit SHA | `15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_REF` | previous commit ref | `refs/heads/main` | | `CI_PREV_COMMIT_REFSPEC` | previous commit ref spec | `issue-branch:main` | | `CI_PREV_COMMIT_BRANCH` | previous commit branch | `main` | | `CI_PREV_COMMIT_SOURCE_BRANCH` | previous commit source branch (set only for pull request events) | `issue-branch` | | `CI_PREV_COMMIT_TARGET_BRANCH` | previous commit target branch (set only for pull request events) | `main` | | `CI_PREV_COMMIT_URL` | previous commit link in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_MESSAGE` | previous commit message | `test` | | `CI_PREV_COMMIT_AUTHOR` | previous commit author username | `john-doe` | | `CI_PREV_COMMIT_AUTHOR_EMAIL` | previous commit author email address | `john-doe@example.com` | | | **Previous pipeline** | | | `CI_PREV_PIPELINE_NUMBER` | previous pipeline number | `7` | | `CI_PREV_PIPELINE_PARENT` | previous pipeline number of parent pipeline | `0` | | `CI_PREV_PIPELINE_EVENT` | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PREV_PIPELINE_EVENT_REASON` | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PREV_PIPELINE_URL` | previous pipeline link in CI | `https://ci.example.com/repos/7/pipeline/7` | | `CI_PREV_PIPELINE_FORGE_URL` | previous pipeline link to event in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_PIPELINE_DEPLOY_TARGET` | previous pipeline deploy target for `deployment` events | `production` | | `CI_PREV_PIPELINE_DEPLOY_TASK` | previous pipeline deploy task for `deployment` events | `migration` | | `CI_PREV_PIPELINE_STATUS` | previous pipeline status | `success`, `failure` | | `CI_PREV_PIPELINE_CREATED` | previous pipeline created UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_STARTED` | previous pipeline started UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_FINISHED` | previous pipeline finished UNIX timestamp | `1722610383` | | `CI_PREV_PIPELINE_AUTHOR` | previous pipeline author username | `octocat` | | `CI_PREV_PIPELINE_AVATAR` | previous pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | |   | | | `CI_WORKSPACE` | Path of the workspace where source code gets cloned to | `/woodpecker/src/git.example.com/john-doe/my-repo` | | | **System** | | | `CI_SYSTEM_NAME` | name of the CI system | `woodpecker` | | `CI_SYSTEM_URL` | link to CI system | `https://ci.example.com` | | `CI_SYSTEM_HOST` | hostname of CI server | `ci.example.com` | | `CI_SYSTEM_VERSION` | version of the server | `2.7.0` | | | **Forge** | | | `CI_FORGE_TYPE` | name of forge | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab` | | `CI_FORGE_URL` | root URL of configured forge | `https://git.example.com` | | | **Internal** - Please don't use! | | | `CI_SCRIPT` | Internal script path. Used to call pipeline step commands. | | | `CI_NETRC_USERNAME` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_PASSWORD` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_MACHINE` | Credentials for private repos to be able to clone data. (Only available for specific images) | | ## Global environment variables If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` These can be used, for example, to manage the image tag used by multiple projects. ```ini WOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18 ``` ```diff steps: - name: build - image: golang:1.18 + image: golang:${GOLANG_VERSION} commands: - [...] ``` ## String Substitution Woodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration. Example commit substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA} ``` Example tag substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG} ``` ## String Operations Woodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values. | OPERATION | DESCRIPTION | | ------------------ | ------------------------------------------------ | | `${param}` | parameter substitution | | `${param,}` | parameter substitution with lowercase first char | | `${param,,}` | parameter substitution with lowercase | | `${param^}` | parameter substitution with uppercase first char | | `${param^^}` | parameter substitution with uppercase | | `${param:pos}` | parameter substitution with substring | | `${param:pos:len}` | parameter substitution with substring and length | | `${param=default}` | parameter substitution with default | | `${param##prefix}` | parameter substitution with prefix removal | | `${param%%suffix}` | parameter substitution with suffix removal | | `${param/old/new}` | parameter substitution with find and replace | Example variable substitution with substring: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA:0:8} ``` Example variable substitution strips `v` prefix from `v.1.0.0`: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG##v} ``` ## `pull_request_metadata` specific event reason values For the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`. **GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list. :::note Event reason values are forge-specific and may change between versions. ::: | Event | GitHub | Gitea | Forgejo | GitLab | Bitbucket | Bitbucket Datacenter | Description | | -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ | | `assigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was assigned to a user | | `converted_to_draft` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Pull request was converted to a draft | | `demilestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was removed from a milestone | | `description_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Description edited | | `edited` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | The title or body of a pull request was edited, or the base branch was changed | | `label_added` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Pull had no labels and now got label(s) added | | `label_cleared` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | All labels removed | | `label_updated` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | New label(s) added / label(s) changed | | `locked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was locked | | `milestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was added to a milestone | | `ready_for_review` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Draft pull request was marked as ready for review | | `review_requested` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | New review was requested | | `title_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Title edited | | `unassigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | User was unassigned from a pull request | | `unlabeled` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Label was removed from a pull request | | `unlocked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was unlocked | **Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214). ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/51-plugins/20-creating-plugins.md ================================================ # Creating plugins Creating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT. ## Settings To allow users to configure the behavior of your plugin, you should use `settings:`. These are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix. Using a setting like `url` results in an env var named `PLUGIN_URL`. Characters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`. CamelCase is not respected, `anInt` get `PLUGIN_ANINT`. ### Basic settings Using any basic YAML type (scalar) will be converted into a string: | Setting | Environment value | | -------------------- | ---------------------------- | | `some-bool: false` | `PLUGIN_SOME_BOOL="false"` | | `some_String: hello` | `PLUGIN_SOME_STRING="hello"` | | `anInt: 3` | `PLUGIN_ANINT="3"` | ### Complex settings It's also possible to use complex settings like this: ```yaml steps: - name: plugin image: foo/plugin settings: complex: abc: 2 list: - 2 - 3 ``` Values like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{"abc": "2", "list": [ "2", "3" ]}`. ### Secrets Secrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage). ## Plugin library For Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See . ## Metadata In your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins). Supported metadata: - `name`: The plugin's full name - `icon`: URL to your plugin's icon - `description`: A short description of what it's doing - `author`: Your name - `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin) - `containerImage`: name of the container image - `containerImageUrl`: link to the container image - `url`: homepage or repository of your plugin If you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required. ## Example plugin This provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline. ### What end users will see The below example demonstrates how we might configure a webhook plugin in the YAML file: ```yaml steps: - name: webhook image: foo/webhook settings: url: https://example.com method: post body: | hello world ``` ### Write the logic Create a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`. ```bash #!/bin/sh curl \ -X ${PLUGIN_METHOD} \ -d ${PLUGIN_BODY} \ ${PLUGIN_URL} ``` ### Package it Create a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint. ```dockerfile # please pin the version, e.g. alpine:3.19 FROM alpine ADD script.sh /bin/ RUN chmod +x /bin/script.sh RUN apk -Uuv add curl ca-certificates ENTRYPOINT /bin/script.sh ``` Build and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community. ```shell docker build -t foo/webhook . docker push foo/webhook ``` Execute your plugin locally from the command line to verify it is working: ```shell docker run --rm \ -e PLUGIN_METHOD=post \ -e PLUGIN_URL=https://example.com \ -e PLUGIN_BODY="hello world" \ foo/webhook ``` ## Best practices - Build your plugin for different architectures to allow many users to use them. At least, you should support `amd64` and `arm64`. - Provide binaries for users using the `local` backend. These should also be built for different OS/architectures. - Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible. - Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names. - Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)). - Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)). ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/51-plugins/51-overview.md ================================================ # Plugins Plugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline. Plugins can be used to deploy code, publish artifacts, send notification, and more. They are automatically pulled from the default container registry the agent's have configured. ```dockerfile title="Dockerfile" FROM cloud/kubectl COPY deploy /usr/local/deploy ENTRYPOINT ["/usr/local/deploy"] ``` ```bash title="deploy" kubectl apply -f $PLUGIN_TEMPLATE ``` ```yaml title=".woodpecker.yaml" steps: - name: deploy-to-k8s image: cloud/my-k8s-plugin settings: template: config/k8s/service.yaml ``` Example pipeline using the Prettier and S3 plugins: ```yaml steps: - name: build image: golang commands: - go build - go test - name: prettier image: woodpeckerci/plugin-prettier - name: publish image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file ``` ## Plugin Isolation Plugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree. While normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author. That's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically adjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands` or `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin anymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition. ## Finding Plugins For official plugins, you can use the Woodpecker plugin index: - [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins) :::tip There are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking. - [Drone Plugins](http://plugins.drone.io) - [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/) - [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community) ::: ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/51-plugins/_category_.yaml ================================================ label: 'Plugins' # position: 2 collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/60-services.md ================================================ # Services Woodpecker provides a services section in the YAML file used for defining service containers. The below configuration composes database and cache containers. Services are accessed using custom hostnames. In the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`. ```yaml steps: - name: build image: golang commands: - go build - go test services: - name: database image: mysql - name: cache image: redis ``` You can define a port and a protocol explicitly: ```yaml services: - name: database image: mysql ports: - 3306 - name: wireguard image: wg ports: - 51820/udp ``` ## Configuration Service containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more. ```diff services: - name: database image: mysql + environment: + MYSQL_DATABASE: test + MYSQL_ALLOW_EMPTY_PASSWORD: yes - name: cache image: redis ``` ## Detachment Service and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required. ```diff steps: - name: build image: golang commands: - go build - go test - name: database image: redis + detach: true - name: test image: golang commands: - go test ``` Containers from detached steps will terminate when the pipeline ends. ## Initialization Service containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff. ```diff steps: - name: test image: golang commands: + - sleep 15 - go get - go test services: - name: database image: mysql ``` ## Complete Pipeline Example ```yaml services: - name: database image: mysql environment: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: example steps: - name: get-version image: ubuntu commands: - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null - sleep 30s # need to wait for mysql-server init - echo 'SHOW VARIABLES LIKE "version"' | mysql -u root -h database test -p example ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/70-volumes.md ================================================ # Volumes Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. :::note Volumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker commands: - docker build --rm -t octocat/hello-world . - docker run --rm octocat/hello-world --test - docker push octocat/hello-world - docker rmi octocat/hello-world volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` If you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`. Please note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error. ```diff -volumes: [ ./certs:/etc/ssl/certs ] +volumes: [ /etc/ssl/certs:/etc/ssl/certs ] ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/72-extensions/40-configuration-extension.md ================================================ # Configuration extension The configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Preprocess the original configuration file with something like Go templating - Convert custom attributes to Woodpecker attributes - Add defaults to the configuration like default steps - Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ... - Centralize configuration for multiple repositories in one place ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your configuration extension. The global configuration will be called before the repository specific configuration extension if both are configured. ```ini title="Server" WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig ``` ## How it works When a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used. ### Request The extension receives an HTTP POST request with the following JSON payload: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc: Netrc; } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to fetch files or other information (like changed files, issues) from the repository using the forge api or even clone the repository. ::: Example request: ```json // Please check the latest structure in the models mentioned above. // This example is likely outdated. { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "myforge.com", "login": "myUser", "password": "myPassword", "type": "forge" } } ``` ### Response The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. ```ts class Response { configs: { name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Example response: ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/72-extensions/_category_.yaml ================================================ label: 'Extensions' # position: 3 collapsible: true collapsed: true link: type: 'doc' id: 'index' ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/72-extensions/index.md ================================================ # Extensions Woodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints. There is currently one type of extension available: - [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly. ## Security :::warning You need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful data like malicious pipeline configurations that could be executed. ::: To prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair. To verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign). You can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page. ## Example extensions A simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions) ## Configuration To prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of: - Built-in networks: - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included. - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet. - `external`: A valid non-private unicast IP, you can access all hosts on public internet. - `*`: All hosts are allowed. - CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6 - (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/72-linter.md ================================================ # Linter Woodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines. ![errors and warnings in UI](./linter-warnings-errors.png) ## Running the linter from CLI You can run the linter also manually from the CLI: ```shell woodpecker-cli lint ``` ## Bad habit warnings Woodpecker warns you if your configuration contains some bad habits. ### Event filter for all steps All your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well. Examples of an **incorrect** config for this rule: ```yaml when: - branch: main - event: tag ``` This will trigger the warning because the first item (`branch: main`) does not filter with an event. ```yaml steps: - name: test when: branch: main - name: deploy when: event: tag ``` Examples of a **correct** config for this rule: ```yaml when: - branch: main event: push - event: tag ``` ```yaml steps: - name: test when: event: [tag, push] - name: deploy when: - event: tag ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/75-project-settings.md ================================================ # Project settings As the owner of a project in Woodpecker you can change project related settings via the web interface. ![project settings](./project-settings.png) ## Pipeline path The path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`. ## Repository hooks Your Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting. ## Allow pull requests Enables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests. ## Allow deployments Enables a pipeline to be started with the `deploy` event from a successful pipeline. :::danger Only activate this option if you trust all users who have push access to your repository. Otherwise, these users will be able to steal secrets that are only available for `deploy` events. ::: ## Require approval for To prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`. ## Trusted If you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes. :::note Only server admins can set this option. If you are not a server admin this option won't be shown in your project settings. ::: ## Custom trusted clone plugins During the clone process, Git credentials (e.g., for private repositories) may be required. These credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html). These credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting. With these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo. To prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level. Without an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step. :::info This setting does not affect subsequent steps, nor does it allow direct pushes to the repository. To enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push). ::: ## Project visibility You can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners. - `Public` Every user can see your project without being logged in. - `Internal` Only authenticated users of the Woodpecker instance can see this project. - `Private` Only you and other owners of the repository can see this project. ## Timeout After this timeout a pipeline has to finish or will be treated as timed out. ## Cancel previous pipelines By enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one. ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/80-badges.md ================================================ # Status Badges Woodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code. ## Badge endpoint ```uri :///api/badges//status.svg ``` The status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter. ```diff -:///api/badges//status.svg +:///api/badges//status.svg?branch= ``` By default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. If you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event: ```diff -:///api/badges//status.svg +:///api/badges//status.svg?events=manual,cron ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/90-advanced-usage.md ================================================ # Advanced usage ## Advanced YAML syntax YAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config: ### Anchors & aliases You can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config. To convert this: ```yaml steps: - name: test image: golang:1.18 commands: go test ./... - name: build image: golang:1.18 commands: build ``` Just add a new section called **variables** like this: ```diff +variables: + - &golang_image 'golang:1.18' steps: - name: test - image: golang:1.18 + image: *golang_image commands: go test ./... - name: build - image: golang:1.18 + image: *golang_image commands: build ``` ### Map merges and overwrites ```yaml variables: - &base-plugin-settings target: dist recursive: false try: true - &special-setting special: true - &some-plugin codeberg.org/6543/docker-images/print_env steps: - name: develop image: *some-plugin settings: <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map when: branch: develop - name: main image: *some-plugin settings: <<: *base-plugin-settings # merge one map and ... try: false # ... overwrite original value ongoing: false # ... adding a new value when: branch: main ``` ### Sequence merges ```yaml variables: pre_cmds: &pre_cmds - echo start - whoami post_cmds: &post_cmds - echo stop hello_cmd: &hello_cmd - echo hello steps: - name: step1 image: debian commands: - <<: *pre_cmds # prepend a sequence - echo exec step now do dedicated things - <<: *post_cmds # append a sequence - name: step2 image: debian commands: - <<: [*pre_cmds, *hello_cmd] # prepend two sequences - echo echo from second step - <<: *post_cmds ``` ### References - [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) - [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml) ## Persisting environment data between steps One can create a file containing environment variables, and then source it in each step that needs them. ```yaml steps: - name: init image: bash commands: - echo "FOO=hello" >> envvars - echo "BAR=world" >> envvars - name: debug image: bash commands: - source ./envvars - echo $FOO ``` ## Declaring global variables As described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables: ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` Note that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps. ## Docker in docker (dind) setup :::warning This set up will only work on trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable "trusted" mode. ::: The snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service. :::note If your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead. ::: First we need to define a service running a docker with the `dind` tag. This service must run in `privileged` mode: ```yaml services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true ports: - 2376 ``` Next, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28). This can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below). ```diff services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true + environment: + DOCKER_TLS_CERTDIR: /dind-certs + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` In the docker client step: 1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon. These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them). 2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`) Test the connection with the docker client: ```diff steps: - name: test image: docker:cli # in production use something like 'docker:-cli' + environment: + DOCKER_HOST: "tcp://docker:2376" + DOCKER_CERT_PATH: "/dind-certs/client" + DOCKER_TLS_VERIFY: "1" + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version ``` This step should output the server and client version information if everything has been set up correctly. Full example: ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/dind-certs/client' DOCKER_TLS_VERIFY: '1' volumes: - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true environment: DOCKER_TLS_CERTDIR: /dind-certs volumes: - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` ================================================ FILE: docs/versioned_docs/version-3.13/20-usage/_category_.yaml ================================================ label: 'Usage' # position: 2 collapsible: true collapsed: false ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/00-general.md ================================================ # General Woodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`). The **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files. The **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance. The **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time). :::tip You can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent. ::: ## Database Woodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page. ## Forge What would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page. ## Container images :::info No `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch. ::: - `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image) - `vX.Y` - `vX` - `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0). - `vX.Y-alpine` - `vX-alpine` - `next`: Built from the `main` branch - `pull_`: Images built from Pull Request branches. Images are pushed to DockerHub and Quay. - woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server)) - woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent)) - woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli)) - woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler)) ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/05-installation/10-docker-compose.md ================================================ # Docker Compose This example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings. It creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information. The server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it. ```yaml title="docker-compose.yaml" services: woodpecker-server: image: woodpeckerci/woodpecker-server:v3 ports: - 8000:8000 volumes: - woodpecker-server-data:/var/lib/woodpecker/ environment: - WOODPECKER_OPEN=true - WOODPECKER_HOST=${WOODPECKER_HOST} - WOODPECKER_GITHUB=true - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: image: woodpeckerci/woodpecker-agent:v3 command: agent restart: always depends_on: - woodpecker-server volumes: - woodpecker-agent-config:/etc/woodpecker - /var/run/docker.sock:/var/run/docker.sock environment: - WOODPECKER_SERVER=woodpecker-server:9000 - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} volumes: woodpecker-server-data: woodpecker-agent-config: ``` Woodpecker must know its own address. You must therefore specify the public address in the format `://`. Please omit any trailing slashes: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_HOST=${WOODPECKER_HOST} ``` It is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR} + - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR} ``` If the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: - [...] + - WOODPECKER_GRPC_SECURE=true # defaults to false + - WOODPECKER_GRPC_VERIFY=true # default ``` As agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] + volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` Agents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: + - WOODPECKER_SERVER=woodpecker-server:9000 ``` The server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Handling sensitive data There are several options for handling sensitive data in `docker compose` or `docker swarm` configurations: For Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure. Alternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret + secrets: + - woodpecker-agent-secret + + secrets: + woodpecker-agent-secret: + external: true ``` To store values in a docker secret you can use the following command: ```bash echo "my_agent_secret_key" | docker secret create woodpecker-agent-secret - ``` ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/05-installation/20-helm-chart.md ================================================ # Helm Chart Woodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments: ```bash helm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version ``` ## Metrics To enable metrics gathering, set the following in values.yml: ```yaml metrics: enabled: true port: 9001 ``` This activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics. To enable both Prometheus pod monitoring discovery, set: ```yaml prometheus: podmonitor: enabled: true interval: 60s labels: {} ``` If you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled: ```yaml # Search all available namespaces podMonitorNamespaceSelector: matchLabels: {} # Enable all available pod monitors podMonitorSelector: matchLabels: {} ``` ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/05-installation/30-packages.md ================================================ # Distribution packages ## Official packages - DEB - RPM The pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution. ```Shell RELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '"tag_name":\s"v\K[^"]+') # Debian/Ubuntu (x86_64) curl -fLOOO "https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb" sudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb # CentOS/RHEL (x86_64) sudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm ``` The package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values. ```ini title="/usr/local/lib/systemd/system/woodpecker-server.service" [Unit] Description=WoodpeckerCI server Documentation=https://woodpecker-ci.org/docs/administration/server-config Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env ConditionPathExists=/etc/woodpecker/woodpecker-server.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-server.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-server WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-server.env" WOODPECKER_OPEN=true WOODPECKER_HOST=${WOODPECKER_HOST} WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` After installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server. ```ini title="/usr/local/lib/systemd/system/woodpecker-agent.service" [Unit] Description=WoodpeckerCI agent Documentation=https://woodpecker-ci.org/docs/administration/configuration/agent Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env ConditionPathExists=/etc/woodpecker/woodpecker-agent.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-agent.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-agent WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-agent.env" WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Community packages :::info Woodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions. ::: - [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=) - [Arch Linux](https://archlinux.org/packages/?q=woodpecker) - [openSUSE](https://software.opensuse.org/package/woodpecker) - [YunoHost](https://apps.yunohost.org/app/woodpecker) - [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html) - [Easypanel](https://easypanel.io/docs/templates/woodpeckerci) ### NixOS :::info This module is not maintained by the Woodpecker developers. If you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained. ::: In theory, the NixOS installation is very similar to the binary installation and supports multiple backends. In practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken. ```nix { config , ... }: let domain = "woodpecker.example.org"; in { # This automatically sets up certificates via let's encrypt security.acme.defaults.email = "acme@example.com"; security.acme.acceptTerms = true; # Setting up a nginx proxy that handles tls for us services.nginx = { enable = true; openFirewall = true; recommendedTlsSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; virtualHosts."${domain}" = { enableACME = true; forceSSL = true; locations."/".proxyPass = "http://localhost:3007"; }; }; services.woodpecker-server = { enable = true; environment = { WOODPECKER_HOST = "https://${domain}"; WOODPECKER_SERVER_ADDR = ":3007"; WOODPECKER_OPEN = "true"; }; # You can pass a file with env vars to the system it could look like: # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX environmentFile = "/path/to/my/secrets/file"; }; # This sets up a woodpecker agent services.woodpecker-agents.agents."docker" = { enable = true; # We need this to talk to the podman socket extraGroups = [ "podman" ]; environment = { WOODPECKER_SERVER = "localhost:9000"; WOODPECKER_MAX_WORKFLOWS = "4"; DOCKER_HOST = "unix:///run/podman/podman.sock"; WOODPECKER_BACKEND = "docker"; }; # Same as with woodpecker-server environmentFile = [ "/var/lib/secrets/woodpecker.env" ]; }; # Here we setup podman and enable dns virtualisation.podman = { enable = true; defaultNetwork.settings = { dns_enabled = true; }; }; # This is needed for podman to be able to talk over dns networking.firewall.interfaces."podman0" = { allowedUDPPorts = [ 53 ]; allowedTCPPorts = [ 53 ]; }; } ``` All configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/05-installation/_category_.yaml ================================================ label: 'Installation' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/10-server.md ================================================ --- toc_max_heading_level: 3 --- # Server ## Forge and User configuration Woodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge. You can also restrict the registration: - closed registration and manually managing users with the CLI `woodpecker-cli user` - open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN` ```ini WOODPECKER_OPEN=false WOODPECKER_ADMIN=john.smith,jane_doe ``` - open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS` ```ini WOODPECKER_OPEN=true WOODPECKER_ORGS=dolores,dog-patch ``` Administrators should also be explicitly set in your configuration. ```ini WOODPECKER_ADMIN=john.smith,jane_doe ``` ## Repository configuration Woodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here. ```ini WOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user ``` ## Databases The default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind: - Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`. - Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably. - Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes. - Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice. ### SQLite By default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] + volumes: + - woodpecker-server-data:/var/lib/woodpecker/ ``` ### MySQL/MariaDB The below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples. The minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information. ```ini WOODPECKER_DATABASE_DRIVER=mysql WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true ``` ### PostgreSQL The below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples. Please use Postgres versions equal or higher than **11**. ```ini WOODPECKER_DATABASE_DRIVER=postgres WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable ``` ## TLS Woodpecker supports SSL configuration by mounting certificates into your container. ```ini WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key ``` TLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library. ### Container configuration In addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] ports: + - 80:80 + - 443:443 - 9000:9000 ``` Additionally, the certificate and key must be mounted and referenced: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: + - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt + - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key volumes: + - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt + - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key ``` ## Reverse Proxy ### Apache This guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration: ```apacheconf ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` You must have these Apache modules installed: - `proxy` - `proxy_http` You must configure Apache to set `X-Forwarded-Proto` when using https. ```diff ProxyPreserveHost On +RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` ### Nginx This guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide). Example configuration: ```nginx server { listen 80; server_name woodpecker.example.com; location / { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` You must configure the proxy to set `X-Forwarded` proxy headers: ```diff server { listen 80; server_name woodpecker.example.com; location / { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` ### Caddy This guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration: ```caddy # expose WebUI and API woodpecker.example.com { reverse_proxy woodpecker-server:8000 } # expose gRPC woodpecker-agent.example.com { reverse_proxy h2c://woodpecker-server:9000 } ``` ### Tunnelmole [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool. Start by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation). After the installation, run the following command to start tunnelmole: ```bash tmole 8000 ``` It will start a tunnel and will give a response like this: ```bash ➜ ~ tmole 8000 http://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 https://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 ``` Set `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server. ### Ngrok [Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command: ```bash ngrok http 8000 ``` Set `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server. ### Traefik To install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https. ```yaml services: server: image: woodpeckerci/woodpecker-server:latest environment: - WOODPECKER_OPEN=true - WOODPECKER_ADMIN=your_admin_user # other settings ... networks: - dmz # externally defined network, so that traefik can connect to the server volumes: - woodpecker-server-data:/var/lib/woodpecker/ deploy: labels: - traefik.enable=true # web server - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000 - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker-secure.tls=true - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-secure.service=woodpecker-service - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker.entrypoints=web - traefik.http.routers.woodpecker.service=woodpecker-service - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker # gRPC service - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000 - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc-secure.tls=true - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc.entrypoints=web - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker volumes: woodpecker-server-data: driver: local networks: dmz: external: true ``` ## Metrics ### Endpoint Woodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above. ```yaml global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` ### Authorization An administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` As an alternative, the token can also be read from a file: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token_file: /etc/secrets/woodpecker-monitoring-token static_configs: - targets: ['woodpecker.domain.com'] ``` ### Reference List of Prometheus metrics specific to Woodpecker: ```yaml # HELP woodpecker_pipeline_count Pipeline count. # TYPE woodpecker_pipeline_count counter woodpecker_pipeline_count{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 woodpecker_pipeline_count{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 # HELP woodpecker_pipeline_time Build time. # TYPE woodpecker_pipeline_time gauge woodpecker_pipeline_time{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 116 woodpecker_pipeline_time{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 155 # HELP woodpecker_pipeline_total_count Total number of builds. # TYPE woodpecker_pipeline_total_count gauge woodpecker_pipeline_total_count 1025 # HELP woodpecker_pending_steps Total number of pending pipeline steps. # TYPE woodpecker_pending_steps gauge woodpecker_pending_steps 0 # HELP woodpecker_repo_count Total number of repos. # TYPE woodpecker_repo_count gauge woodpecker_repo_count 9 # HELP woodpecker_running_steps Total number of running pipeline steps. # TYPE woodpecker_running_steps gauge woodpecker_running_steps 0 # HELP woodpecker_user_count Total number of users. # TYPE woodpecker_user_count gauge woodpecker_user_count 1 # HELP woodpecker_waiting_steps Total number of pipeline waiting on deps. # TYPE woodpecker_waiting_steps gauge woodpecker_waiting_steps 0 # HELP woodpecker_worker_count Total number of workers. # TYPE woodpecker_worker_count gauge woodpecker_worker_count 4 ``` ## External Configuration API To provide additional management and preprocessing capabilities for pipeline configurations Woodpecker supports an HTTP API which can be enabled to call an external config service. Before the run or restart of any pipeline Woodpecker will make a POST request to an external HTTP API sending the current repository, build information and all current config files retrieved from the repository. The external API can then send back new pipeline configurations that will be used immediately or respond with `HTTP 204` to tell the system to use the existing configuration. Every request sent by Woodpecker is signed using a [http-signature](https://datatracker.ietf.org/doc/html/rfc9421) by a private key (ed25519) generated on the first start of the Woodpecker server. You can get the public key for the verification of the http-signature from `http(s)://your-woodpecker-server/api/signature/public-key`. A simplistic example configuration service can be found here: [https://github.com/woodpecker-ci/example-config-service](https://github.com/woodpecker-ci/example-config-service) :::warning You need to trust the external config service as it is getting secret information about the repository and pipeline and has the ability to change pipeline configs that could run malicious tasks. ::: ### Configuration ```ini title="Server" WOODPECKER_CONFIG_SERVICE_ENDPOINT=https://example.com/ciconfig ``` #### Example request made by Woodpecker ```json { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipe", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-file-name.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "https://example.com", "login": "user", "password": "password" } } ``` #### Example response structure ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ## UI customization Woodpecker supports custom JS and CSS files. These files must be present in the server's filesystem. They can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment. The configuration variables are independent of each other, which means it can be just one file present, or both. ```ini WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js ``` The examples below show how to place a banner message in the top navigation bar of Woodpecker. ```css title="woodpecker.css" .banner-message { position: absolute; width: 280px; height: 40px; margin-left: 240px; margin-top: 5px; padding-top: 5px; font-weight: bold; background: red no-repeat; text-align: center; } ``` ```javascript title="woodpecker.js" // place/copy a minified version of your preferred lightweight JavaScript library here ... !(function () { 'use strict'; function e() {} /*...*/ })(); $().ready(function () { $('.app nav img').first().htmlAfter(""); }); ``` ## Environment variables ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### LOG_FILE - Name: `WOODPECKER_LOG_FILE` - Default: `stderr` Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. --- ### DATABASE_LOG - Name: `WOODPECKER_DATABASE_LOG` - Default: `false` Enable logging in database engine (currently xorm). --- ### DATABASE_LOG_SQL - Name: `WOODPECKER_DATABASE_LOG_SQL` - Default: `false` Enable logging of sql commands. --- ### DATABASE_MAX_CONNECTIONS - Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS` - Default: `100` Max database connections xorm is allowed create. --- ### DATABASE_IDLE_CONNECTIONS - Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS` - Default: `2` Amount of database connections xorm will hold open. --- ### DATABASE_CONNECTION_TIMEOUT - Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT` - Default: `3 Seconds` Time an active database connection is allowed to stay open. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOST - Name: `WOODPECKER_HOST` - Default: none Server fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix. Examples: - `WOODPECKER_HOST=http://woodpecker.example.org` - `WOODPECKER_HOST=http://example.org/woodpecker` - `WOODPECKER_HOST=http://example.org:1234/woodpecker` --- ### SERVER_ADDR - Name: `WOODPECKER_SERVER_ADDR` - Default: `:8000` Configures the HTTP listener port. --- ### SERVER_ADDR_TLS - Name: `WOODPECKER_SERVER_ADDR_TLS` - Default: `:443` Configures the HTTPS listener port when SSL is enabled. --- ### SERVER_CERT - Name: `WOODPECKER_SERVER_CERT` - Default: none Path to an SSL certificate used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_CERT=/path/to/cert.pem` --- ### SERVER_KEY - Name: `WOODPECKER_SERVER_KEY` - Default: none Path to an SSL certificate key used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_KEY=/path/to/key.pem` --- ### CUSTOM_CSS_FILE - Name: `WOODPECKER_CUSTOM_CSS_FILE` - Default: none File path for the server to serve a custom .CSS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css` --- ### CUSTOM_JS_FILE - Name: `WOODPECKER_CUSTOM_JS_FILE` - Default: none File path for the server to serve a custom .JS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js` --- ### GRPC_ADDR - Name: `WOODPECKER_GRPC_ADDR` - Default: `:9000` Configures the gRPC listener port. --- ### GRPC_SECRET - Name: `WOODPECKER_GRPC_SECRET` - Default: `secret` Configures the gRPC JWT secret. --- ### GRPC_SECRET_FILE - Name: `WOODPECKER_GRPC_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GRPC_SECRET` from the specified filepath. --- ### METRICS_SERVER_ADDR - Name: `WOODPECKER_METRICS_SERVER_ADDR` - Default: none Configures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely. Example: `:9001` --- ### ADMIN - Name: `WOODPECKER_ADMIN` - Default: none Comma-separated list of admin accounts. Example: `WOODPECKER_ADMIN=user1,user2` --- ### ORGS - Name: `WOODPECKER_ORGS` - Default: none Comma-separated list of approved organizations. Example: `org1,org2` --- ### REPO_OWNERS - Name: `WOODPECKER_REPO_OWNERS` - Default: none Repositories by those owners will be allowed to be used in woodpecker. Example: `user1,user2` --- ### OPEN - Name: `WOODPECKER_OPEN` - Default: `false` Enable to allow user registration. --- ### AUTHENTICATE_PUBLIC_REPOS - Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS` - Default: `false` Always use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies. --- ### DEFAULT_ALLOW_PULL_REQUESTS - Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS` - Default: `true` The default setting for allowing pull requests on a repo. --- ### DEFAULT_APPROVAL_MODE - Name: `WOODPECKER_DEFAULT_APPROVAL_MODE` - Default: `forks` The default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`. --- ### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS - Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS` - Default: `pull_request, push` List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created. --- ### DEFAULT_CLONE_PLUGIN - Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN` - Default: `docker.io/woodpeckerci/plugin-git` The default docker image to be used when cloning the repo. It is also added to the trusted clone plugin list. ### DEFAULT_WORKFLOW_LABELS - Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS` - Default: none You can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set. Example: `platform=linux/amd64,backend=docker` ### DEFAULT_PIPELINE_TIMEOUT - Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT` - Default: 60 The default time for a repo in minutes before a pipeline gets killed ### MAX_PIPELINE_TIMEOUT - Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT` - Default: 120 The maximum time in minutes you can set in the repo settings before a pipeline gets killed --- ### SESSION_EXPIRES - Name: `WOODPECKER_SESSION_EXPIRES` - Default: `72h` Configures the session expiration time. Context: when someone does log into Woodpecker, a temporary session token is created. As long as the session is valid (until it expires or log-out), a user can log into Woodpecker, without re-authentication. ### PLUGINS_PRIVILEGED - Name: `WOODPECKER_PLUGINS_PRIVILEGED` - Default: none Docker images to run in privileged mode. Only change if you are sure what you do! You should specify the tag of your images too, as this enforces exact matches. ### PLUGINS_TRUSTED_CLONE - Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE` - Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git` Plugins which are trusted to handle the Git credential info in clone steps. If a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos. You should specify the tag of your images too, as this enforces exact matches. --- ### DOCKER_CONFIG - Name: `WOODPECKER_DOCKER_CONFIG` - Default: none Configures a specific private registry config for all pipelines. Example: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json` --- ### ENVIRONMENT - Name: `WOODPECKER_ENVIRONMENT` - Default: none If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. Example: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2` --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath --- ### DISABLE_USER_AGENT_REGISTRATION - Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION` - Default: false By default, users can create new agents for their repos they have admin access to. If an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements. :::note You should set this option if you have, for example, global secrets and don't trust your users to create a rogue agent and pipeline for secret extraction. ::: --- ### KEEPALIVE_MIN_TIME - Name: `WOODPECKER_KEEPALIVE_MIN_TIME` - Default: none Server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping. Example: `WOODPECKER_KEEPALIVE_MIN_TIME=10s` --- ### DATABASE_DRIVER - Name: `WOODPECKER_DATABASE_DRIVER` - Default: `sqlite3` The database driver name. Possible values are `sqlite3`, `mysql` or `postgres`. --- ### DATABASE_DATASOURCE - Name: `WOODPECKER_DATABASE_DATASOURCE` - Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container The database connection string. The default value is the path of the embedded SQLite database file. Example: ```bash # MySQL # https://github.com/go-sql-driver/mysql#dsn-data-source-name WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true # PostgreSQL # https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable ``` --- ### DATABASE_DATASOURCE_FILE - Name: `WOODPECKER_DATABASE_DATASOURCE_FILE` - Default: none Read the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath --- ### PROMETHEUS_AUTH_TOKEN - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN` - Default: none Token to secure the Prometheus metrics endpoint. Must be set to enable the endpoint. --- ### PROMETHEUS_AUTH_TOKEN_FILE - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE` - Default: none Read the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath --- ### STATUS_CONTEXT - Name: `WOODPECKER_STATUS_CONTEXT` - Default: `ci/woodpecker` Context prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository. --- ### STATUS_CONTEXT_FORMAT - Name: `WOODPECKER_STATUS_CONTEXT_FORMAT` - Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}` Template for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language. Supported variables: - `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`) - `event`: the event which started the pipeline - `workflow`: the workflow's name - `owner`: the repo's owner - `repo`: the repo's name --- ### CONFIG_SERVICE_ENDPOINT - Name: `WOODPECKER_CONFIG_SERVICE_ENDPOINT` - Default: none Specify a configuration service endpoint, see [Configuration Extension](#external-configuration-api) --- ### EXTENSIONS_ALLOWED_HOSTS - Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` - Default: `external` Comma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list. --- ### FORGE_TIMEOUT - Name: `WOODPECKER_FORGE_TIMEOUT` - Default: 5s Specify timeout when fetching the Woodpecker configuration from forge. See for syntax reference. --- ### FORGE_RETRY - Name: `WOODPECKER_FORGE_RETRY` - Default: 3 Specify how many retries of fetching the Woodpecker configuration from a forge are done before we fail. --- ### ENABLE_SWAGGER - Name: `WOODPECKER_ENABLE_SWAGGER` - Default: true Enable the Swagger UI for API documentation. --- ### DISABLE_VERSION_CHECK - Name: `WOODPECKER_DISABLE_VERSION_CHECK` - Default: false Disable version check in admin web UI. --- ### LOG_STORE - Name: `WOODPECKER_LOG_STORE` - Default: `database` Where to store logs. Possible values: - `database`: stores the logs in the database - `file`: stores logs in JSON files on the files system - `addon`: uses an [addon](./100-addons.md#log) to store logs --- ### LOG_STORE_FILE_PATH - Name: `WOODPECKER_LOG_STORE_FILE_PATH` - Default: none If [`WOODPECKER_LOG_STORE`](#log_store) is: - `file`: Directory to store logs in - `addon`: The path to the addon executable --- ### EXPERT_WEBHOOK_HOST - Name: `WOODPECKER_EXPERT_WEBHOOK_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `://[/]`. --- ### EXPERT_FORGE_OAUTH_HOST - Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified public forge URL, used if forge url is not a public URL. Format: `://[/]`. --- ### GITHUB\_\* See [GitHub configuration](./12-forges/20-github.md#configuration) --- ### GITEA\_\* See [Gitea configuration](./12-forges/30-gitea.md#configuration) --- ### BITBUCKET\_\* See [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration) --- ### GITLAB\_\* See [GitLab configuration](./12-forges/40-gitlab.md#configuration) ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/100-addons.md ================================================ # Addons Addons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service. :::warning Addon forges are still experimental. Their implementation can change and break at any time. ::: :::danger You must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information. ::: ## Usage To use an addon forge, download the correct addon version. ### Forge Use this in your `.env`: ```ini WOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file ``` In case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`. #### List of addon forges - [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws). ### Log Use this in your `.env`: ```ini WOODPECKER_LOG_STORE=addon WOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file ``` ## Developing addon forges See [Addons](../../92-development/100-addons.md). ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/10-docker.md ================================================ --- toc_max_heading_level: 2 --- # Docker This is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent. ## Private registries Woodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config). To add your credential helper to the Woodpecker server container you could use the following code to build a custom image: ```dockerfile FROM woodpeckerci/woodpecker-server:latest-alpine RUN apk add -U --no-cache docker-credential-ecr-login ``` ## Step specific configuration ### Run user By default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group: ```yaml steps: - name: example image: alpine commands: - whoami backend_options: docker: user: 65534:65534 ``` The syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag. ## Tips and tricks ### Image cleanup The agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner. :::danger The following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation. ::: - Remove all unused images ```bash docker image rm $(docker images --filter "dangling=true" -q --no-trunc) ``` - Remove Woodpecker volumes ```bash docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true -q) ``` ### Podman There is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog). ## Environment variables ### BACKEND_DOCKER_NETWORK - Name: `WOODPECKER_BACKEND_DOCKER_NETWORK` - Default: none Set to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other! --- ### BACKEND_DOCKER_ENABLE_IPV6 - Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6` - Default: `false` Enable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6. --- ### BACKEND_DOCKER_VOLUMES - Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES` - Default: none List of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA certificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`. --- ### BACKEND_DOCKER_LIMIT_MEM_SWAP - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP` - Default: `0` The maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_MEM - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM` - Default: `0` The maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_SHM_SIZE - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE` - Default: `0` The maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_QUOTA - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA` - Default: `0` The number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_SHARES - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES` - Default: `0` The relative weight vs. other containers. --- ### BACKEND_DOCKER_LIMIT_CPU_SET - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET` - Default: none Comma-separated list to limit the specific CPUs or cores a pipeline container can use. Example: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2` ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/20-kubernetes.md ================================================ --- toc_max_heading_level: 2 --- # Kubernetes The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps. ## Metadata labels Woodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies. The following metadata labels are supported: - `woodpecker-ci.org/forge-id` - `woodpecker-ci.org/repo-forge-id` - `woodpecker-ci.org/repo-id` - `woodpecker-ci.org/repo-name` - `woodpecker-ci.org/repo-full-name` - `woodpecker-ci.org/branch` - `woodpecker-ci.org/org-id` - `woodpecker-ci.org/task-uuid` - `woodpecker-ci.org/step` ## Private registries In addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML. Place these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`. ## Step specific configuration ### Resources The Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory. We recommend to add a `resources` definition to all steps to ensure efficient scheduling. Here is an example definition with an arbitrary `resources` definition below the `backend_options` section: ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: resources: requests: memory: 200Mi cpu: 100m limits: memory: 400Mi cpu: 1000m ``` You can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis. ### Runtime class `runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes. ### Service account `serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts. ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: # Use the service account `default` in the current namespace. # This usually the same as wherever woodpecker is deployed. serviceAccountName: default ``` To give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) ### Node selector `nodeSelector` specifies the labels which are used to select the node on which the step will be executed. Labels defined here will be appended to a list which already contains `"kubernetes.io/arch"`. By default `"kubernetes.io/arch"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`. Without a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures. To overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`. A practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture. In this case, one must define an arbitrary key in the matrix section of the respective matrix element: ```yaml matrix: include: - NAME: runner1 ARCH: arm64 ``` And then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var: ```yaml [...] backend_options: kubernetes: nodeSelector: kubernetes.io/arch: "${ARCH}" ``` You can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent or [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis. ### Tolerations When you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations. Example pipeline configuration: ```yaml steps: - name: build image: golang commands: - go get - go build - go test backend_options: kubernetes: serviceAccountName: 'my-service-account' resources: requests: memory: 128Mi cpu: 1000m limits: memory: 256Mi nodeSelector: beta.kubernetes.io/instance-type: Standard_D2_v3 tolerations: - key: 'key1' operator: 'Equal' value: 'value1' effect: 'NoSchedule' tolerationSeconds: 3600 affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: topology.kubernetes.io/zone operator: In values: - eu-central-1a - eu-central-1b ``` ### Affinity Kubernetes [affinity and anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity) rules allow you to constrain which nodes your pods can be scheduled on based on node labels, or co-locate/spread pods relative to other pods. You can configure affinity at two levels: 1. **Per-step via `backend_options.kubernetes.affinity`** (shown in example above) - requires agent configuration to allow it 2. **Agent-wide via `WOODPECKER_BACKEND_K8S_POD_AFFINITY`** - applies to all pods unless overridden #### Agent-wide affinity To apply affinity rules to all workflow pods, configure the agent with YAML-formatted affinity: ```yaml WOODPECKER_BACKEND_K8S_POD_AFFINITY: | nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node-role.kubernetes.io/worker operator: In values: - "true" ``` By default, per-step affinity settings are **not allowed** for security reasons. To enable them: ```bash WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP: true ``` :::warning Enabling `WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP` in multi-tenant environments allows pipeline authors to control pod placement, which may have security or resource isolation implications. ::: When per-step affinity is allowed and specified, it **replaces** the agent-wide affinity entirely (not merged). #### Example: agent affinity for co-location This example configures all workflow pods within a workflow to be co-located on the same node, while requiring other workflows run on different nodes. It uses `matchLabelKeys` to dynamically match pods with the same `woodpecker-ci.org/task-uuid`, and `mismatchLabelKeys` to separating pods with different task UUIDs: ```yaml WOODPECKER_BACKEND_K8S_POD_AFFINITY: | podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: {} matchLabelKeys: - woodpecker-ci.org/task-uuid topologyKey: "kubernetes.io/hostname" podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: {} mismatchLabelKeys: - woodpecker-ci.org/task-uuid topologyKey: "kubernetes.io/hostname" ``` :::note The `matchLabelKeys` and `mismatchLabelKeys` features require Kubernetes v1.29+ (alpha with feature gate `MatchLabelKeysInPodAffinity`) or v1.33+ (beta, enabled by default). These fields allow the Kubernetes API server to dynamically populate label selectors at pod creation time, eliminating the need to hardcode values like `$(WOODPECKER_TASK_UUID)`. ::: #### Example: Node affinity for GPU workloads Ensure a step runs only on GPU-enabled nodes: ```yaml steps: - name: train-model image: tensorflow/tensorflow:latest-gpu backend_options: kubernetes: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: accelerator operator: In values: - nvidia-tesla-v100 ``` ### Volumes To mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option. Persistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference. _If your PVC is not highly available or NFS-based, use the `affinity` settings (documented above) to ensure that your steps are executed on the correct node._ NOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver: ```yaml accessModes: - ReadWriteMany ``` Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step: ```yaml steps: - name: "Restore Cache" image: meltwater/drone-cache volumes: - woodpecker-cache:/woodpecker/src/cache settings: mount: - "woodpecker-cache" [...] ``` Or as follows when using a normal image: ```yaml steps: - name: "Edit cache" image: alpine:latest volumes: - woodpecker-cache:/woodpecker/src/cache commands: - echo "Hello World" > /woodpecker/src/cache/output.txt [...] ``` ### Security context Use the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step: ```yaml steps: - name: test image: alpine commands: - echo Hello world backend_options: kubernetes: securityContext: runAsUser: 999 runAsGroup: 999 privileged: true [...] ``` Note that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object. By default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the configuration shown above will result in something like the following Pod spec: ```yaml kind: Pod spec: securityContext: runAsUser: 999 runAsGroup: 999 containers: - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0 image: alpine securityContext: privileged: true [...] ``` You can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile. ```yaml backend_options: kubernetes: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/audit.json ``` or restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile ```yaml backend_options: kubernetes: securityContext: apparmorProfile: type: Localhost localhostProfile: k8s-apparmor-example-deny-write ``` or configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always') ```yaml backend_options: kubernetes: securityContext: fsGroupChangePolicy: OnRootMismatch ``` :::note The feature requires Kubernetes v1.30 or above. ::: ### Annotations and labels You can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration: ```yaml backend_options: kubernetes: annotations: workflow-group: alpha io.kubernetes.cri-o.Devices: /dev/fuse labels: environment: ci app.kubernetes.io/name: builder ``` In order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent: [WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step). ## Tips and tricks ### CRI-O CRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration: ```yaml workspace: base: '/woodpecker' path: '/' ``` See [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details. ### `KUBERNETES_SERVICE_HOST` environment variable Like the below env vars used for configuration, this can be set in the environment for configuration of the agent. It configures the address of the Kubernetes API server to connect to. If running the agent within Kubernetes, this will already be set and you don't have to add it manually. ### Headless services For each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created, and all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname. Using the headless services, the step pod is connected to directly, so any port on the other step pods can be reached. This is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service. ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/woodpecker/dind-certs/client' DOCKER_TLS_VERIFY: '1' commands: - docker run hello-world - name: docker image: docker:dind # use 'docker:-dind' or similar in production detached: true privileged: true environment: DOCKER_TLS_CERTDIR: /woodpecker/dind-certs ``` If ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP. ## Environment variables These env vars can be set in the `env:` sections of the agent. --- ### BACKEND_K8S_NAMESPACE - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE` - Default: `woodpecker` The namespace to create worker Pods in. --- ### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION` - Default: `false` Enables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation. With this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker. ### BACKEND_K8S_VOLUME_SIZE - Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE` - Default: `10G` The volume size of the pipeline volume. --- ### BACKEND_K8S_STORAGE_CLASS - Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS` - Default: none The storage class to use for the pipeline volume. --- ### BACKEND_K8S_STORAGE_RWX - Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX` - Default: `true` Determines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead. --- ### BACKEND_K8S_POD_LABELS - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS` - Default: none Additional labels to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-label":"test-value"}`. --- ### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP` - Default: `false` Determines if additional Pod labels can be defined from a step's backend options. --- ### BACKEND_K8S_POD_ANNOTATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS` - Default: none Additional annotations to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-annotation":"test-value"}`. --- ### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP` - Default: `false` Determines if Pod annotations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_TOLERATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS` - Default: none Additional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{"effect":"NoSchedule","key":"jobs","operator":"Exists"}]`. --- ### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP` - Default: `true` Determines if Pod tolerations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_NODE_SELECTOR - Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR` - Default: none Additional node selector to apply to worker pods. Must be a YAML object, e.g. `{"topology.kubernetes.io/region":"eu-central-1"}`. --- ### BACKEND_K8S_SECCTX_NONROOT - Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT` - Default: `false` Determines if containers must be required to run as non-root users. --- ### BACKEND_K8S_PULL_SECRET_NAMES - Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` - Default: none Secret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/). --- ### BACKEND_K8S_PRIORITY_CLASS - Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS` - Default: none, which will use the default priority class configured in Kubernetes Which [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/30-local.md ================================================ --- toc_max_heading_level: 2 --- # Local :::danger The local backend executes pipelines on the local system without any isolation. ::: :::note Currently we do not support [services](../../../20-usage/60-services.md) for this backend. [Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095). ::: Since the commands run directly in the same context as the agent (same user, same filesystem), a malicious pipeline could be used to access the agent configuration especially the `WOODPECKER_AGENT_SECRET` variable. It is recommended to use this backend only for private setup where the code and pipeline can be trusted. It should not be used in a public instance where anyone can submit code or add new repositories. The agent should not run as a privileged user (root). The local backend will use a random directory in `$TMPDIR` to store the cloned code and execute commands. In order to use this backend, you need to download (or build) the [agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine. ## Step specific configuration ### Shell The `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is used to run the commands. ```yaml title=".woodpecker.yaml" steps: - name: build image: bash commands: [...] ``` ### Plugins ```yaml steps: - name: build image: /usr/bin/tree ``` If no commands are provided, plugins are treated in the usual manner. In the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path. ## Environment variables ### BACKEND_LOCAL_TEMP_DIR - Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` - Default: default temp directory Directory to create folders for workflows. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/50-custom.md ================================================ # Custom If none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend: ```go package main import ( "go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core" backendTypes "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func main() { core.RunAgent([]backendTypes.Backend{ yourBackend, }) } ``` ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/11-backends/_category_.yaml ================================================ label: 'Backends' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/11-overview.md ================================================ # Forges ## Supported features | Feature | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) | | ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- | | Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | Event: Deploy¹ | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | | [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | [Multiple workflows](../../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [when.path filter](../../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | ¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks. In addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/20-github.md ================================================ --- toc_max_heading_level: 2 --- # GitHub Woodpecker comes with built-in support for GitHub and GitHub Enterprise. To use Woodpecker with GitHub the following environment variables should be set for the server component: ```ini WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID WOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET ``` You will get these values from GitHub when you register your OAuth application. To do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App. :::warning Do not use a "GitHub App" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically) ::: ## App Settings - Name: An arbitrary name for your App - Homepage URL: The URL of your Woodpecker instance - Callback URL: `https:///authorize` - (optional) Upload the Woodpecker Logo: ## Client Secret Creation After your App has been created, you can generate a client secret. Use this one for the `WOODPECKER_GITHUB_SECRET` environment variable. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITHUB - Name: `WOODPECKER_GITHUB` - Default: `false` Enables the GitHub driver. --- ### GITHUB_URL - Name: `WOODPECKER_GITHUB_URL` - Default: `https://github.com` Configures the GitHub server address. --- ### GITHUB_CLIENT - Name: `WOODPECKER_GITHUB_CLIENT` - Default: none Configures the GitHub OAuth client id to authorize access. --- ### GITHUB_CLIENT_FILE - Name: `WOODPECKER_GITHUB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath. --- ### GITHUB_SECRET - Name: `WOODPECKER_GITHUB_SECRET` - Default: none Configures the GitHub OAuth client secret. This is used to authorize access. --- ### GITHUB_SECRET_FILE - Name: `WOODPECKER_GITHUB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath. --- ### GITHUB_MERGE_REF - Name: `WOODPECKER_GITHUB_MERGE_REF` - Default: `true` --- ### GITHUB_SKIP_VERIFY - Name: `WOODPECKER_GITHUB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### GITHUB_PUBLIC_ONLY - Name: `WOODPECKER_GITHUB_PUBLIC_ONLY` - Default: `false` Configures the GitHub OAuth client to only obtain a token that can manage public repositories. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/30-gitea.md ================================================ --- toc_max_heading_level: 2 --- # Gitea Woodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITEA=true WOODPECKER_GITEA_URL=YOUR_GITEA_URL WOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT WOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET ``` ## Gitea on the same host with containers If you have Gitea also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `gitea`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea ``` ## Registration Register your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. If you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook). ![gitea oauth setup](gitea_oauth.gif) :::warning Make sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITEA - Name: `WOODPECKER_GITEA` - Default: `false` Enables the Gitea driver. --- ### GITEA_URL - Name: `WOODPECKER_GITEA_URL` - Default: `https://try.gitea.io` Configures the Gitea server address. --- ### GITEA_CLIENT - Name: `WOODPECKER_GITEA_CLIENT` - Default: none Configures the Gitea OAuth client id. This is used to authorize access. --- ### GITEA_CLIENT_FILE - Name: `WOODPECKER_GITEA_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath --- ### GITEA_SECRET - Name: `WOODPECKER_GITEA_SECRET` - Default: none Configures the Gitea OAuth client secret. This is used to authorize access. --- ### GITEA_SECRET_FILE - Name: `WOODPECKER_GITEA_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITEA_SECRET` from the specified filepath --- ### GITEA_SKIP_VERIFY - Name: `WOODPECKER_GITEA_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/35-forgejo.md ================================================ --- toc_max_heading_level: 2 --- # Forgejo Woodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_FORGEJO=true WOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL WOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT WOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET ``` ## Forgejo on the same host with containers If you have Forgejo also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `forgejo`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo ``` ## Registration Register your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. If you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook). ![forgejo oauth setup](gitea_oauth.gif) :::warning Make sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### FORGEJO - Name: `WOODPECKER_FORGEJO` - Default: `false` Enables the Forgejo driver. --- ### FORGEJO_URL - Name: `WOODPECKER_FORGEJO_URL` - Default: `https://next.forgejo.org` Configures the Forgejo server address. --- ### FORGEJO_CLIENT - Name: `WOODPECKER_FORGEJO_CLIENT` - Default: none Configures the Forgejo OAuth client id. This is used to authorize access. --- ### FORGEJO_CLIENT_FILE - Name: `WOODPECKER_FORGEJO_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath --- ### FORGEJO_SECRET - Name: `WOODPECKER_FORGEJO_SECRET` - Default: none Configures the Forgejo OAuth client secret. This is used to authorize access. --- ### FORGEJO_SECRET_FILE - Name: `WOODPECKER_FORGEJO_SECRET_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath --- ### FORGEJO_SKIP_VERIFY - Name: `WOODPECKER_FORGEJO_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/40-gitlab.md ================================================ --- toc_max_heading_level: 2 --- # GitLab Woodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITLAB=true WOODPECKER_GITLAB_URL=http://gitlab.mycompany.com WOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82 WOODPECKER_GITLAB_SECRET=30f5064039e6b359e075 ``` ## Registration You must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application. Please use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application. If you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITLAB - Name: `WOODPECKER_GITLAB` - Default: `false` Enables the GitLab driver. --- ### GITLAB_URL - Name: `WOODPECKER_GITLAB_URL` - Default: `https://gitlab.com` Configures the GitLab server address. --- ### GITLAB_CLIENT - Name: `WOODPECKER_GITLAB_CLIENT` - Default: none Configures the GitLab OAuth client id. This is used to authorize access. --- ### GITLAB_CLIENT_FILE - Name: `WOODPECKER_GITLAB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath --- ### GITLAB_SECRET - Name: `WOODPECKER_GITLAB_SECRET` - Default: none Configures the GitLab OAuth client secret. This is used to authorize access. --- ### GITLAB_SECRET_FILE - Name: `WOODPECKER_GITLAB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath --- ### GITLAB_SKIP_VERIFY - Name: `WOODPECKER_GITLAB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/50-bitbucket.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Woodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_BITBUCKET=true WOODPECKER_BITBUCKET_CLIENT=... # called "Key" in Bitbucket WOODPECKER_BITBUCKET_SECRET=... ``` ## Registration You must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`). Please set a name and set the `Callback URL` like this: ```uri https:///authorize ``` ![bitbucket oauth setup](bitbucket_oauth.png) Please also be sure to check the following permissions: - Account: Email, Read - Workspace membership: Read - Projects: Read - Repositories: Read - Pull requests: Read - Webhooks: Read and Write ![bitbucket permissions](bitbucket_permissions.png) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET - Name: `WOODPECKER_BITBUCKET` - Default: `false` Enables the Bitbucket driver. --- ### BITBUCKET_CLIENT - Name: `WOODPECKER_BITBUCKET_CLIENT` - Default: none Configures the Bitbucket OAuth client key. This is used to authorize access. --- ### BITBUCKET_CLIENT_FILE - Name: `WOODPECKER_BITBUCKET_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath --- ### BITBUCKET_SECRET - Name: `WOODPECKER_BITBUCKET_SECRET` - Default: none Configures the Bitbucket OAuth client secret. This is used to authorize access. --- ### BITBUCKET_SECRET_FILE - Name: `WOODPECKER_BITBUCKET_SECRET_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath ## Known Issues Bitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details. ## Missing Features Path filters for pull requests are not supported. We are interested in patches to include this functionality. If you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de). ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Datacenter / Server :::warning Woodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash. ::: To enable Bitbucket Server you should configure the Woodpecker container using the following environment variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_BITBUCKET_DC=true + - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo + - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar + - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx + - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy + - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com + - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true woodpecker-agent: [...] ``` ## Service Account Woodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories. ## Registration Woodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose "Application Links" and then "Create link". Woodpecker should be listed as "External Application" and the direction should be set to "Incoming". Note the client id and client secret of the registration to be used in the configuration of Woodpecker. See also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html). ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET_DC - Name: `WOODPECKER_BITBUCKET_DC` - Default: `false` Enables the Bitbucket Server driver. --- ### BITBUCKET_DC_URL - Name: `WOODPECKER_BITBUCKET_DC_URL` - Default: none Configures the Bitbucket Server address. --- ### BITBUCKET_DC_CLIENT_ID - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID` - Default: none Configures your Bitbucket Server OAUth 2.0 client id. --- ### BITBUCKET_DC_CLIENT_SECRET - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET` - Default: none Configures your Bitbucket Server OAUth 2.0 client secret. --- ### BITBUCKET_DC_GIT_USERNAME - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` - Default: none This username is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_USERNAME_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath --- ### BITBUCKET_DC_GIT_PASSWORD - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` - Default: none The password is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_PASSWORD_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath --- ### BITBUCKET_DC_SKIP_VERIFY - Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN - Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN` - Default: `false` When enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/12-forges/_category_.yaml ================================================ label: 'Forges' collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/30-agent.md ================================================ --- toc_max_heading_level: 3 --- # Agent Agents are configured by the command line or environment variables. At the minimum you need the following information: ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" ``` The following are automatically set and can be overridden: - `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname - `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1 ## Workflows per agent By default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent. ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" WOODPECKER_MAX_WORKFLOWS=4 ``` ## Agent registration When the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before. There are two types of tokens to connect an agent to the server: ### Using system token A _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents. In that case registration process would be as following: 1. The first time the agent communicates with the server, it is using the system token 1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent 1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`) 1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server ### Using agent token An _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`. To get an _agent token_ you have to register the agent manually in the server using the UI: 1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent` ![Agent creation](./new-agent-registration.png) ![Agent created](./new-agent-created.png) 1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET` 1. The agent will connect to the server using the provided token and will update its status in the UI: ![Agent connected](./new-agent-connected.png) ## Environment variables ### SERVER - Name: `WOODPECKER_SERVER` - Default: `localhost:9000` Configures gRPC address of the server. --- ### USERNAME - Name: `WOODPECKER_USERNAME` - Default: `x-oauth-basic` The gRPC username. --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf` --- ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOSTNAME - Name: `WOODPECKER_HOSTNAME` - Default: none Configures the agent hostname. --- ### AGENT_CONFIG_FILE - Name: `WOODPECKER_AGENT_CONFIG_FILE` - Default: `/etc/woodpecker/agent.conf` Configures the path of the agent config file. --- ### MAX_WORKFLOWS - Name: `WOODPECKER_MAX_WORKFLOWS` - Default: `1` Configures the number of parallel workflows. --- ### AGENT_LABELS - Name: `WOODPECKER_AGENT_LABELS` - Default: none Configures custom labels for the agent, to let workflows filter by it. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. If you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched. By default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels). --- ### HEALTHCHECK - Name: `WOODPECKER_HEALTHCHECK` - Default: `true` Enable healthcheck endpoint. --- ### HEALTHCHECK_ADDR - Name: `WOODPECKER_HEALTHCHECK_ADDR` - Default: `:3000` Configures healthcheck endpoint address. --- ### KEEPALIVE_TIME - Name: `WOODPECKER_KEEPALIVE_TIME` - Default: none After a duration of this time of no activity, the agent pings the server to check if the transport is still alive. --- ### KEEPALIVE_TIMEOUT - Name: `WOODPECKER_KEEPALIVE_TIMEOUT` - Default: `20s` After pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity. --- ### GRPC_SECURE - Name: `WOODPECKER_GRPC_SECURE` - Default: `false` Configures if the connection to `WOODPECKER_SERVER` should be made using a secure transport. --- ### GRPC_VERIFY - Name: `WOODPECKER_GRPC_VERIFY` - Default: `true` Configures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`. --- ### BACKEND - Name: `WOODPECKER_BACKEND` - Default: `auto-detect` Configures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`. ### BACKEND_DOCKER\_\* See [Docker backend configuration](./11-backends/10-docker.md#environment-variables) --- ### BACKEND_K8S\_\* See [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables) --- ### BACKEND_LOCAL\_\* See [Local backend configuration](./11-backends/30-local.md#environment-variables) ### Advanced Settings :::warning Only change these If you know what you do. ::: #### CONNECT_RETRY_COUNT - Name: `WOODPECKER_CONNECT_RETRY_COUNT` - Default: `5` Configures number of times agent retries to connect to the server. #### CONNECT_RETRY_DELAY - Name: `WOODPECKER_CONNECT_RETRY_DELAY` - Default: `2s` Configures delay between agent connection retries to the server. ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/40-autoscaler.md ================================================ # Autoscaler If your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler). Please note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap). ## Setup ### docker compose If you are using docker compose you can add the following to your `docker-compose.yaml` file: ```yaml services: woodpecker-server: image: woodpeckerci/woodpecker-server:next [...] woodpecker-autoscaler: image: woodpeckerci/autoscaler:next restart: always depends_on: - woodpecker-server environment: - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user - WOODPECKER_MIN_AGENTS=0 - WOODPECKER_MAX_AGENTS=3 - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include "https://" in the value. - WOODPECKER_GRPC_SECURE=true - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud ``` ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/10-configuration/_category_.yaml ================================================ label: 'Configuration' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.13/30-administration/_category_.yaml ================================================ label: 'Administration' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.13/40-cli.md ================================================ # CLI # NAME woodpecker-cli - command line utility # SYNOPSIS woodpecker-cli ``` [--config|-c]=[value] [--disable-update-check] [--log-file]=[value] [--log-level]=[value] [--nocolor] [--pretty] [--server|-s]=[value] [--skip-verify] [--socks-proxy-off] [--socks-proxy]=[value] [--token|-t]=[value] ``` # DESCRIPTION Woodpecker command line utility **Usage**: ``` woodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...] ``` # GLOBAL OPTIONS **--config, -c**="": path to config file **--disable-update-check**: disable update check (default: false) **--log-file**="": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr) **--log-level**="": set logging level (default: info) **--nocolor**: disable colored debug output, only has effect if pretty output is set too (default: false) **--pretty**: enable pretty-printed debug output (default: true) **--server, -s**="": server address **--skip-verify**: skip ssl verification (default: false) **--socks-proxy**="": socks proxy address **--socks-proxy-off**: socks proxy ignored (default: false) **--token, -t**="": server auth token # COMMANDS ## admin manage server settings ### log-level retrieve log level from server, or set it with [level] ### org manage organizations #### ls list organizations **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nOrganization ID: {{ .ID }}\n) ### registry manage global registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username ### secret manage global secrets #### add add a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--value**="": secret value #### rm remove a secret **--name**="": secret name #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name #### update update a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--value**="": secret value ### user manage users #### add add a user #### ls list all users **--format**="": format output (default: {{ .Login }}) #### rm remove a user #### show show user information **--format**="": format output (default: User: {{ .Login }}\nEmail: {{ .Email }}) ## context, ctx manage contexts ### list, ls list all contexts **--output**="": output format (default: table) **--output-no-headers**: do not print headers in output (default: false) **--output-no-headers**: don't print headers (default: false) ### use set the current context ### delete, rm delete a context ### rename rename a context ## exec execute a local pipeline **--backend-docker-api-version**="": the version of the API to reach, leave empty for latest. **--backend-docker-cert**="": path to load the TLS certificates for connecting to docker server **--backend-docker-host**="": path to docker socket or url to the docker server **--backend-docker-ipv6**: backend docker enable IPV6 (default: false) **--backend-docker-limit-cpu-quota**="": impose a cpu quota (default: 0) **--backend-docker-limit-cpu-set**="": set the cpus allowed to execute containers **--backend-docker-limit-cpu-shares**="": change the cpu shares (default: 0) **--backend-docker-limit-mem**="": maximum memory allowed in bytes (default: 0) **--backend-docker-limit-mem-swap**="": maximum memory used for swap in bytes (default: 0) **--backend-docker-limit-shm-size**="": docker /dev/shm allowed in bytes (default: 0) **--backend-docker-network**="": backend docker network **--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server (default: true) **--backend-docker-volumes**="": backend docker volumes (comma separated) **--backend-engine**="": backend engine to run pipelines on (default: auto-detect) **--backend-http-proxy**="": if set, pass the environment variable down as "HTTP_PROXY" to steps **--backend-https-proxy**="": if set, pass the environment variable down as "HTTPS_PROXY" to steps **--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps (default: false) **--backend-k8s-namespace**="": backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name. (default: woodpecker) **--backend-k8s-namespace-per-org**: Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization. (default: false) **--backend-k8s-pod-affinity**="": backend k8s Agent-wide worker pod affinity, in YAML format **--backend-k8s-pod-affinity-allow-from-step**: whether to allow using affinity from step's backend options (default: false) **--backend-k8s-pod-annotations**="": backend k8s additional Agent-wide worker pod annotations **--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options (default: false) **--backend-k8s-pod-image-pull-secret-names**="": backend k8s pull secret names for private registries **--backend-k8s-pod-labels**="": backend k8s additional Agent-wide worker pod labels **--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options (default: false) **--backend-k8s-pod-node-selector**="": backend k8s Agent-wide worker pod node selector **--backend-k8s-pod-tolerations**="": backend k8s Agent-wide worker pod tolerations **--backend-k8s-pod-tolerations-allow-from-step**: whether to allow using tolerations from step's backend options (default: true) **--backend-k8s-priority-class**="": which kubernetes priority class to assign to created job pods **--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option (default: false) **--backend-k8s-storage-class**="": backend k8s storage class **--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) (default: true) **--backend-k8s-volume-size**="": backend k8s volume size (default 10G) (default: 10G) **--backend-local-temp-dir**="": set a different temp dir to clone workflows into (default: system temporary directory) **--backend-no-proxy**="": if set, pass the environment variable down as "NO_PROXY" to steps **--commit-author-avatar**="": Set the metadata environment variable "CI_COMMIT_AUTHOR_AVATAR". **--commit-author-email**="": Set the metadata environment variable "CI_COMMIT_AUTHOR_EMAIL". **--commit-author-name**="": Set the metadata environment variable "CI_COMMIT_AUTHOR". **--commit-branch**="": Set the metadata environment variable "CI_COMMIT_BRANCH". (default: main) **--commit-message**="": Set the metadata environment variable "CI_COMMIT_MESSAGE". **--commit-pull-labels**="": Set the metadata environment variable "CI_COMMIT_PULL_REQUEST_LABELS". **--commit-pull-milestone**="": Set the metadata environment variable "CI_COMMIT_PULL_REQUEST_MILESTONE". **--commit-ref**="": Set the metadata environment variable "CI_COMMIT_REF". **--commit-refspec**="": Set the metadata environment variable "CI_COMMIT_REFSPEC". **--commit-release-is-pre**: Set the metadata environment variable "CI_COMMIT_PRERELEASE". (default: false) **--commit-sha**="": Set the metadata environment variable "CI_COMMIT_SHA". **--env**="": Set the metadata environment variable "CI_ENV". **--forge-type**="": Set the metadata environment variable "CI_FORGE_TYPE". **--forge-url**="": Set the metadata environment variable "CI_FORGE_URL". **--local**: run from local directory (default: true) **--metadata-file**="": path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags **--netrc-machine**="": **--netrc-password**="": **--netrc-username**="": **--network**="": external networks **--pipeline-changed-files**="": Set the metadata environment variable "CI_PIPELINE_FILES", either json formatted list of strings, or comma separated string list. **--pipeline-created**="": Set the metadata environment variable "CI_PIPELINE_CREATED". (default: 0) **--pipeline-deploy-task**="": Set the metadata environment variable "CI_PIPELINE_DEPLOY_TASK". **--pipeline-deploy-to**="": Set the metadata environment variable "CI_PIPELINE_DEPLOY_TARGET". **--pipeline-event**="": Set the metadata environment variable "CI_PIPELINE_EVENT". (default: manual) **--pipeline-number**="": Set the metadata environment variable "CI_PIPELINE_NUMBER". (default: 0) **--pipeline-parent**="": Set the metadata environment variable "CI_PIPELINE_PARENT". (default: 0) **--pipeline-started**="": Set the metadata environment variable "CI_PIPELINE_STARTED". (default: 0) **--pipeline-url**="": Set the metadata environment variable "CI_PIPELINE_FORGE_URL". **--plugins-privileged**="": Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none **--prev-commit-author-avatar**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR_AVATAR". **--prev-commit-author-email**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR_EMAIL". **--prev-commit-author-name**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR". **--prev-commit-branch**="": Set the metadata environment variable "CI_PREV_COMMIT_BRANCH". **--prev-commit-message**="": Set the metadata environment variable "CI_PREV_COMMIT_MESSAGE". **--prev-commit-ref**="": Set the metadata environment variable "CI_PREV_COMMIT_REF". **--prev-commit-refspec**="": Set the metadata environment variable "CI_PREV_COMMIT_REFSPEC". **--prev-commit-sha**="": Set the metadata environment variable "CI_PREV_COMMIT_SHA". **--prev-pipeline-created**="": Set the metadata environment variable "CI_PREV_PIPELINE_CREATED". (default: 0) **--prev-pipeline-deploy-task**="": Set the metadata environment variable "CI_PREV_PIPELINE_DEPLOY_TASK". **--prev-pipeline-deploy-to**="": Set the metadata environment variable "CI_PREV_PIPELINE_DEPLOY_TARGET". **--prev-pipeline-event**="": Set the metadata environment variable "CI_PREV_PIPELINE_EVENT". **--prev-pipeline-finished**="": Set the metadata environment variable "CI_PREV_PIPELINE_FINISHED". (default: 0) **--prev-pipeline-number**="": Set the metadata environment variable "CI_PREV_PIPELINE_NUMBER". (default: 0) **--prev-pipeline-started**="": Set the metadata environment variable "CI_PREV_PIPELINE_STARTED". (default: 0) **--prev-pipeline-status**="": Set the metadata environment variable "CI_PREV_PIPELINE_STATUS". **--prev-pipeline-url**="": Set the metadata environment variable "CI_PREV_PIPELINE_FORGE_URL". **--repo**="": Set the full name to derive metadata environment variables "CI_REPO", "CI_REPO_NAME" and "CI_REPO_OWNER". **--repo-clone-ssh-url**="": Set the metadata environment variable "CI_REPO_CLONE_SSH_URL". **--repo-clone-url**="": Set the metadata environment variable "CI_REPO_CLONE_URL". **--repo-default-branch**="": Set the metadata environment variable "CI_REPO_DEFAULT_BRANCH". (default: main) **--repo-path**="": path to local repository **--repo-private**="": Set the metadata environment variable "CI_REPO_PRIVATE". **--repo-remote-id**="": Set the metadata environment variable "CI_REPO_REMOTE_ID". **--repo-trusted-network**: Set the metadata environment variable "CI_REPO_TRUSTED_NETWORK". (default: false) **--repo-trusted-security**: Set the metadata environment variable "CI_REPO_TRUSTED_SECURITY". (default: false) **--repo-trusted-volumes**: Set the metadata environment variable "CI_REPO_TRUSTED_VOLUMES". (default: false) **--repo-url**="": Set the metadata environment variable "CI_REPO_URL". **--secrets**="": map of secrets, ex. 'secret="val",secret2="value2"' **--secrets-file**="": path to yaml file with secrets map **--system-host**="": Set the metadata environment variable "CI_SYSTEM_HOST". **--system-name**="": Set the metadata environment variable "CI_SYSTEM_NAME". (default: woodpecker) **--system-platform**="": Set the metadata environment variable "CI_SYSTEM_PLATFORM". **--system-url**="": Set the metadata environment variable "CI_SYSTEM_URL". (default: https://github.com/woodpecker-ci/woodpecker) **--timeout**="": pipeline timeout (default: 1h0m0s) **--volumes**="": pipeline volumes **--workflow-name**="": Set the metadata environment variable "CI_WORKFLOW_NAME". **--workflow-number**="": Set the metadata environment variable "CI_WORKFLOW_NUMBER". (default: 0) **--workspace-base**="": (default: /woodpecker) **--workspace-path**="": (default: src) ## info show information about the current user **--format**="": format output (deprecated) (default: User: {{ .Login }}\nEmail: {{ .Email }}) ## lint lint a pipeline configuration file **--plugins-privileged**="": allow plugins to run in privileged mode, if set empty, there is no **--plugins-trusted-clone**="": plugins that are trusted to handle Git credentials in cloning steps (default: "docker.io/woodpeckerci/plugin-git:2.8.0", "docker.io/woodpeckerci/plugin-git", "quay.io/woodpeckerci/plugin-git") **--strict**: treat warnings as errors (default: false) ## org manage organizations ### registry manage organization registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username ### secret manage secrets #### add add a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--value**="": secret value #### rm remove a secret **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a secret **--event**="": limit secret to these event **--image**="": limit secret to these image **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--value**="": secret value ## pipeline manage pipelines ### approve approve a pipeline ### create create new pipeline **--branch**="": branch to create pipeline from **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) **--var**="": key=value ### decline decline a pipeline ### deploy trigger a pipeline with the 'deployment' event **--branch**="": branch filter **--event**="": event filter (default: push) **--format**="": format output (default: Number: {{ .Number }}\nStatus: {{ .Status }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nMessage: {{ .Message }}\nAuthor: {{ .Author }}\nTarget: {{ .Deploy }}\n) **--param, -p**="": custom parameters to inject into the step environment. Format: KEY=value **--status**="": status filter (default: success) ### last show latest pipeline information **--branch**="": branch name (default: main) **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### ls show pipeline history **--after**="": only return pipelines after this date (RFC3339) **--before**="": only return pipelines before this date (RFC3339) **--branch**="": branch filter **--event**="": event filter **--limit**="": limit the list size (default: 25) **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) **--status**="": status filter ### log manage logs #### purge purge a log #### show show pipeline logs ### ps show pipeline steps **--format**="": format output (default: \x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\x1b[0m\nStep: {{ .step.Name }}\nStarted: {{ .step.Started }}\nStopped: {{ .step.Stopped }}\nType: {{ .step.Type }}\nState: {{ .step.State }}\n) ### purge purge pipelines **--branch**="": remove pipelines of this branch only **--dry-run**: disable non-read api calls (default: false) **--keep-min**="": minimum number of pipelines to keep (default: 10) **--older-than**="": remove pipelines older than the specified time limit (default: 0s) ### queue show pipeline queue **--format**="": format output (default: \x1b[33m{{ .FullName }} #{{ .Number }} \x1b[0m\nStatus: {{ .Status }}\nEvent: {{ .Event }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\nMessage: {{ .Message }}\n) ### show show pipeline information **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### start start a pipeline **--param, -p**="": custom parameters to inject into the step environment. Format: KEY=value ### stop stop a pipeline ## repo manage repositories ### add add a repository ### chown assume ownership of a repository ### cron manage cron jobs #### add add a cron job **--branch**="": cron branch **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule #### rm remove a cron job **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list cron jobs **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show cron job information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a cron job **--branch**="": cron branch **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--id**="": cron id **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule ### ls list all repos **--all**: query all repos, including inactive ones (default: false) **--format**="": format output (deprecated) **--org**="": filter by organization **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### registry manage registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username ### rm remove a repository ### repair repair repository webhooks ### secret manage secrets #### add add a secret **--event**="": limit secret to these events **--image**="": limit secret to these images **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value #### rm remove a secret **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a secret **--event**="": limit secret to these events **--image**="": limit secret to these images **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value ### show show repository information **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### sync synchronize the repository list **--format**="": format output (default: \x1b[33m{{ .FullName }}\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})) ### update update a repository **--config**="": repository configuration path. Example: .woodpecker.yml **--pipeline-counter**="": repository starting pipeline number (default: 0) **--require-approval**="": repository requires approval for **--timeout**="": repository timeout (default: 0s) **--trusted-network**: repository is network trusted (default: false) **--trusted-security**: repository is security trusted (default: false) **--trusted-volumes**: repository is volumes trusted (default: false) **--unsafe**: allow unsafe operations (default: false) **--visibility**="": repository visibility ## setup setup the woodpecker-cli for the first time **--context, --ctx**="": name for the context (defaults to 'default') **--server**="": URL of the woodpecker server **--token**="": token to authenticate with the woodpecker server ## update update the woodpecker-cli to the latest version **--force**: force update even if the latest version is already installed (default: false) ================================================ FILE: docs/versioned_docs/version-3.13/92-development/01-getting-started.md ================================================ # Getting started You can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea). ## Gitpod If you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing: - An IDE in the browser or bridged to your local VS-Code or Jetbrains - A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge - A pre-configured Woodpecker server - A single pre-configured Woodpecker agent node - Our docs preview server Start Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`. [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker) ## Preparation for local development ### Install Go Install Golang as described by [this guide](https://go.dev/doc/install). ### Install make > GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (). Install make on: - Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/) - [Windows](https://stackoverflow.com/a/32127632/8461267) - Mac OS: `brew install make` ### Install Node.js & `pnpm` Install [Node.js](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation. For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used. [This guide](https://pnpm.io/installation) describes the installation of `pnpm`. ### Install `pre-commit` (optional) Woodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code. To apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage). ### Create a `.env` file with your development configuration Similar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it. A common config for debugging would look like this: ```ini WOODPECKER_OPEN=true WOODPECKER_ADMIN=your-username WOODPECKER_HOST=http://localhost:8000 # github (sample for a forge config - see /docs/administration/forge/overview for other forges) WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT= WOODPECKER_GITHUB_SECRET= # agent WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system WOODPECKER_MAX_WORKFLOWS=1 # enable if you want to develop the UI # WOODPECKER_DEV_WWW_PROXY=http://localhost:8010 # if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server WOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com # disable health-checks while debugging (normally not needed while developing) WOODPECKER_HEALTHCHECK=false # WOODPECKER_LOG_LEVEL=debug # WOODPECKER_LOG_LEVEL=trace ``` ### Setup OAuth Create an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md). ## Developing with VS Code You can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it. To launch all needed services for local development, you can use "Woodpecker CI" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it. As a starting guide for programming Go with VS Code, you can use this video guide: [![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80) ### Debugging Woodpecker The Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points. ![Woodpecker debugging with VS Code](./vscode-debug.png) ## Testing & linting code To test or lint parts of Woodpecker, you can run one of the following commands: ```bash # test server code make test-server # test agent code make test-agent # test cli code make test-cli # test datastore / database related code like migrations of the server make test-server-datastore # lint go code make lint # lint UI code make lint-frontend # test UI code make test-frontend ``` If you want to test a specific Go file, you can also use: ```bash go test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/ ``` Or you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands: ![Run test via VS-Code](./vscode-run-test.png) ## Run applications from terminal If you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor. ```bash title="start server" go run ./cmd/server ``` ```bash title="start agent" go run ./cmd/agent ``` ```bash title="execute cli command" go run ./cmd/cli [command] ``` ================================================ FILE: docs/versioned_docs/version-3.13/92-development/02-core-ideas.md ================================================ # Core ideas - A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂). - If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle). - What is used most often should be default. - Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md). ## Addons and extensions If you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an [addon](../30-administration/10-configuration/100-addons.md), [extension](../30-administration/10-configuration/10-server.md#external-configuration-api) or an [external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points: - Is your change very specific to your setup and unlikely to be used by anyone else? - Does your change violate the [guidelines](#guidelines)? Both should be false when you open a pull request to get your change into the core repository. ### Guidelines #### Forges A new forge must support these features: - OAuth2 - Webhooks ================================================ FILE: docs/versioned_docs/version-3.13/92-development/03-ui.md ================================================ # UI Development To develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api. ## Setup The UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed). Testing UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files. ![UI Proxy architecture](./ui-proxy.svg) Start the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file. After starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000). ### Usage with remote server If you would like to test your UI changes on a "real-world" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables: - `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org` - `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser Then, open the UI at `http://localhost:8010`. ## Tools and frameworks The following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing. - [Vue 3](https://v3.vuejs.org/) - use `setup` and composition api - place (re-usable) components in `web/src/components/` - views should have a route in `web/src/router.ts` and are located in `web/src/views/` - [Tailwind CSS](https://tailwindcss.com/) - use Tailwind classes where possible - if needed extend the Tailwind config to use new classes - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier) - [Vite](https://vitejs.dev/) (similar to Webpack) - [Typescript](https://www.typescriptlang.org/) - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:) - [eslint](https://eslint.org/) - [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471) ## Messages and Translations Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source. You must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet) For more information about translations see [Translations](./08-translations.md). ================================================ FILE: docs/versioned_docs/version-3.13/92-development/04-docs.md ================================================ # Documentation The documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/). If you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands: ```bash cd docs/ pnpm install # build plugins used by the docs pnpm build:woodpecker-plugins # start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually pnpm start # or build the docs to deploy it to some static page hosting pnpm build ``` ================================================ FILE: docs/versioned_docs/version-3.13/92-development/05-architecture.md ================================================ # Architecture ## Package architecture ![Woodpecker architecture](./woodpecker-architecture.png) ## System architecture ### main package hierarchy | package | meaning | imports | | ------------------ | -------------------------------------------------------------- | ------------------------------------- | | `cmd/**` | parse command-line args & environment to stat server/cli/agent | all other | | `agent/**` | code only agent (remote worker) will need | `pipeline`, `shared` | | `cli/**` | code only cli tool does need | `pipeline`, `shared`, `woodpecker-go` | | `server/**` | code only server will need | `pipeline`, `shared` | | `shared/**` | code shared for all three main tools (go help utils) | only std and external libs | | `woodpecker-go/**` | go client for server rest api | std | ### Server | package | meaning | imports | | -------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server/api/**` | handle web requests from `server/router` | `pipeline`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) | | `server/badges/**` | generate svg badges for pipelines | `../model` | | `server/ccmenu/**` | generate xml ccmenu for pipelines | `../model` | | `server/grpc/**` | gRPC server agents can connect to | `pipeline/rpc/**`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store` | | `server/logging/**` | logging lib for gPRC server to stream logs while running | std | | `server/model/**` | structs for store (db) and api (json) | std | | `server/plugins/**` | plugins for server | `../model`, `../forge` | | `server/pipeline/**` | orchestrate pipelines | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins` | | `server/pubsub/**` | pubsub lib for server to push changes to the WebUI | std | | `server/queue/**` | queue lib for server where agents pull new pipelines from via gRPC | `server/model` | | `server/forge/**` | forge lib for server to connect and handle forge specific stuff | `shared`, `server/model` | | `server/router/**` | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web` | | `server/store/**` | handle database | `server/model` | | `server/shared/**` | TODO: move and split [#974](https://github.com/woodpecker-ci/woodpecker/issues/974) | | | `server/web/**` | server SPA | | - `../` = `server/` ### Agent TODO ### CLI TODO ================================================ FILE: docs/versioned_docs/version-3.13/92-development/06-conventions.md ================================================ # Conventions ## Database naming Database tables are named plural, columns don't have any prefix. Example: Model name `Agent` with table name `agents` and columns `id`, `name`. ================================================ FILE: docs/versioned_docs/version-3.13/92-development/07-guides.md ================================================ # Guides ## ORM Woodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection. ## Add a new migration Woodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`. :::info Adding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created. ::: :::warning You should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager. ::: To automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start. ## Constants of official images All official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag. ## Building images locally ### Server ```sh ### build web component make vendor cd web/ pnpm install --frozen-lockfile pnpm build cd .. ### define the platforms to build for (e.g. linux/amd64) # (the | is not a typo here) export PLATFORMS='linux|amd64' make cross-compile-server ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push . ``` :::info The `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)). You can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS). ::: ### Agent ```sh ### build the agent make build-agent ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push . ``` ### CLI ```sh ### build the CLI make build-cli ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push . ``` ================================================ FILE: docs/versioned_docs/version-3.13/92-development/08-translations.md ================================================ # Translations To translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.** Translation status Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. ================================================ FILE: docs/versioned_docs/version-3.13/92-development/09-openapi.md ================================================ # Swagger, API Spec and Code Generation Woodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically generate Swagger v2 API specifications and a nice looking Web UI from the source code. Also, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger) and then being using on the community's website documentation. It's paramount important to keep the gin handler function's godoc documentation up-to-date, to always have accurate API documentation. Whenever you change, add or enhance an API endpoint, please update the godoc. You don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools. ## Gin-Handler API documentation guideline Here's a typical example of how annotations for Swagger documentation look like... ```go title="server/api/user.go" // @Summary Get a user // @Description Returns a user with the specified login name. Requires admin rights. // @Router /users/{login} [get] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param foobar query string false "optional foobar parameter" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) ``` ```go title="server/model/user.go" type User struct { ID int64 `json:"id" xorm:"pk autoincr 'user_id'"` // ... } // @name User ``` These guidelines aim to have consistent wording in the OpenAPI doc: - first word after `@Summary` and `@Summary` are always uppercase - `@Summary` has no `.` (dot) at the end of the line - model structs shall use custom short names, to ease life for API consumers, using `@name` - `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI - when pagination is used, `@Param page` and `@Param perPage` must be added manually - `@Param Authorization` is almost always present, there are just a few un-protected endpoints There are many examples in the `server/api` package, which you can use a blueprint. More enhanced information you can find here ### Manual code generation ```bash title="generate the server's Go code containing the OpenAPI" make generate-openapi ``` ```bash title="update the Markdown in the ./docs folder" make generate-docs ``` ================================================ FILE: docs/versioned_docs/version-3.13/92-development/09-testing.md ================================================ # Testing ## Backend ### Unit Tests [We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test) with [`"github.com/stretchr/testify/assert"`](https://pkg.go.dev/github.com/stretchr/testify@v1.9.0/assert) to simplify testing. ### Integration Tests ### Dummy backend There is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave. To enable it you need to build the agent or cli with the `test` build tag. An example pipeline config would be: ```yaml when: event: manual steps: - name: echo image: dummy commands: echo "hello woodpecker" environment: SLEEP: '1s' services: echo: image: dummy commands: echo "i am a service" ``` This could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`: ```none 9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: service [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9 [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: commands [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 ``` There are also environment variables to alter step behavior: - `SLEEP: 10` will let the step wait 10 seconds - `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands` - `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled) - `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs - `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0 - `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains You can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`. ================================================ FILE: docs/versioned_docs/version-3.13/92-development/10-packaging.md ================================================ # Packaging If you repackage it, we encourage to build from source, which requires internet connection. For offline builds, we also offer a tarball with all vendored dependencies and a pre-built web UI on the [release page](https://github.com/woodpecker-ci/woodpecker/releases). ## Distribute web UI in own directory If you do not want to embed the web UI in the binary, you can compile a custom root path for the web UI into the binary. Add `external_web` to the tags and use the build flag `-X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/some/path` to set a custom path. Example: ```sh go build -tags 'external_web' -ldflags '-s -w -extldflags "-static" -X go.woodpecker-ci.org/woodpecker/v3/version.Version=3.12.0 -X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/nix/store/maaajlp8h5gy9zyjgfhaipzj07qnnmrl-woodpecker-WebUI-3.12.0' -o dist/woodpecker-server go.woodpecker-ci.org/woodpecker/v3/cmd/server ``` ================================================ FILE: docs/versioned_docs/version-3.13/92-development/100-addons.md ================================================ # Addons The Woodpecker server supports addons for forges and the log store. :::warning Addons are still experimental. Their implementation can change and break at any time. ::: ## Bug reports If you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name. ## Creating addons Addons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin). ### Writing your code This example will use the Go language. Directly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there. In the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument. This will take care of connecting the addon forge to the server. :::note It is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process. ::: ### Example structure This is an example for a forge addon. ```go package main import ( "context" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/forge/addon" forgeTypes "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func main() { addon.Serve(config{}) } type config struct { } // `config` must implement `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge`. You must directly use Woodpecker's packages - see imports above. ``` ### Addon types | Type | Addon package | Service interface | | --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | | Forge | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge` | | Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/service/log".Service` | ================================================ FILE: docs/versioned_docs/version-3.13/92-development/_category_.yaml ================================================ label: 'Development' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.14/10-intro/index.md ================================================ # Welcome to Woodpecker Woodpecker is a CI/CD tool. It is designed to be lightweight, simple to use and fast. Before we dive into the details, let's have a look at some of the basics. ## Have you ever heard of CI/CD or pipelines? Don't worry if you haven't. We'll guide you through the basics. CI/CD stands for Continuous Integration and Continuous Deployment. It's basically like a conveyor belt that moves your code from development to production doing all kinds of checks, tests and routines along the way. A typical pipeline might include the following steps: 1. Running tests 2. Building your application 3. Deploying your application [Have a deeper look into the idea of CI/CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd) ## Do you know containers? If you are already using containers in your daily workflow, you'll for sure love Woodpecker. If not yet, you'll be amazed how easy it is to get started with [containers](https://opencontainers.org/). ## Already have access to a Woodpecker instance? Then you might want to jump directly into it and [start creating your first pipelines](../20-usage/10-intro.md). ## Want to start from scratch and deploy your own Woodpecker instance? Woodpecker is lightweight and even runs on a Raspberry Pi. You can follow the [deployment guide](../30-administration/00-general.md) to set up your own Woodpecker instance. ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/10-intro.md ================================================ # Your first pipeline Let's get started and create your first pipeline. ## 1. Repository Activation To activate your repository in Woodpecker navigate to the repository list and `New repository`. You will see a list of repositories from your forge (GitHub, Gitlab, ...) which can be activated with a simple click. ![new repository list](repo-new.png) To enable a repository in Woodpecker you must have `Admin` rights on that repository, so that Woodpecker can add something that is called a webhook (Woodpecker needs it to know about actions like pushes, pull requests, tags, etc.). ## 2. Define first workflow After enabling a repository Woodpecker will listen for changes in your repository. When a change is detected, Woodpecker will check for a pipeline configuration. So let's create a file at `.woodpecker/my-first-workflow.yaml` inside your repository: ```yaml title=".woodpecker/my-first-workflow.yaml" when: - event: push branch: main steps: - name: build image: debian commands: - echo "This is the build step" - echo "binary-data-123" > executable - name: a-test-step image: golang:1.16 commands: - echo "Testing ..." - ./executable ``` **So what did we do here?** 1. We defined your first workflow file `my-first-workflow.yaml`. 2. This workflow will be executed when a push event happens on the `main` branch, because we added a filter using the `when` section: ```diff + when: + - event: push + branch: main ... ``` 3. We defined two steps: `build` and `a-test-step` The steps are executed in the order they are defined, so `build` will be executed first and then `a-test-step`. In the `build` step we use the `debian` image and build a "binary file" called `executable`. In the `a-test-step` we use the `golang:1.16` image and run the `executable` file to test it. You can use any image from registries like the [Docker Hub](https://hub.docker.com/search?type=image) you have access to: ```diff steps: - name: build - image: debian + image: my-company/image-with-aws_cli commands: - aws help ``` ## 3. Push the file and trigger first pipeline If you push this file to your repository now, Woodpecker will already execute your first pipeline. You can check the pipeline execution in the Woodpecker UI by navigating to the `Pipelines` section of your repository. ![pipeline view](./pipeline.png) As you probably noticed, there is another step in called `clone` which is executed before your steps. This step clones your repository into a folder called `workspace` which is available throughout all steps. This for example allows the first step to build your application using your source code and as the second step will receive the same workspace it can use the previously built binary and test it. ## 4. Use a plugin for reusable tasks Sometimes you have some tasks that you need to do in every project. For example, deploying to Kubernetes or sending a Slack message. Therefore you can use one of the [official and community plugins](/plugins) or simply [create your own](./51-plugins/20-creating-plugins.md). If you want to publish a file to an S3 bucket, you can add an S3 plugin to your pipeline: ```yaml steps: # ... - name: upload image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name access_key: a50d28f4dd477bc184fbd10b376de753 secret_key: from_secret: aws_secret_key source: public/**/* target: /target/location ``` To configure a plugin you can use the `settings` section. Sometime you need to provide secrets to the plugin. You can do this by using the `from_secret` key. The secret must be defined in the Woodpecker UI. You can find more information about secrets [here](./40-secrets.md). Similar to the `when` section at the top of the file which is for the complete workflow, you can use the `when` section for each step to define when a step should be executed. Learn more about [plugins](./51-plugins/51-overview.md). As you now have a basic understanding of how to create a pipeline, you can dive deeper into the [workflow syntax](./20-workflow-syntax.md) and [plugins](./51-plugins/51-overview.md). ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/100-troubleshooting.md ================================================ # Troubleshooting ## How to debug clone issues (And what to do with an error message like `fatal: could not read Username for 'https://': No such device or address`) This error can have multiple causes. If you use internal repositories you might have to enable `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS`: ```ini WOODPECKER_AUTHENTICATE_PUBLIC_REPOS=true ``` If that does not work, try to make sure the container can reach your git server. In order to do that disable git checkout and make the container "hang": ```yaml skip_clone: true steps: build: image: debian:stable-backports commands: - apt update - apt install -y inetutils-ping wget - ping -c 4 git.example.com - wget git.example.com - sleep 9999999 ``` Get the container id using `docker ps` and copy the id from the first column. Enter the container with: `docker exec -it 1234asdf bash` (replace `1234asdf` with the docker id). Then try to clone the git repository with the commands from the failing pipeline: ```bash git init git remote add origin https://git.example.com/username/repo.git git fetch --no-tags origin +refs/heads/branch: ``` (replace the url AND the branch with the correct values, use your username and password as log in values) ## SELinux Issues When running Woodpecker on systems with SELinux enabled (such as RHEL, CentOS, Fedora, or other Enterprise Linux distributions), SELinux may prevent the agent from accessing the Docker socket. ### Symptoms If SELinux is blocking access, you may see errors like: ```text permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock ``` ### Solutions There are several ways to resolve this: #### Option 1: Set SELinux to Permissive Mode (For Testing Only) Set SELinux to permissive mode temporarily to verify it's the issue: ```bash setenforce 0 ``` To permanently set SELinux to permissive mode: ```bash # Edit /etc/selinux/config SELINUX=permissive ``` #### Option 2: Configure SELinux Policy (Recommended) Create a custom SELinux policy to allow Woodpecker agent to access Docker: ```bash # Generate the policy module ausearch -c 'docker' -avc | audit2allow -R -o woodpecker-docker.te # Build the policy module checkmodule -M -m -o woodpecker-docker.mod woodpecker-docker.te semodule_package -o woodpecker-docker.pp -m woodpecker-docker.mod # Load the policy module semodule -i woodpecker-docker.pp ``` #### Option 3: Use Docker Volume with SELinux Options When using Docker Compose or Docker, add the `:z` or `:Z` option to volume mounts: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock:z ``` The `:z` option tells Docker to automatically relabel the volume content for SELinux. Use `:Z` with caution as it relabels the volume exclusively for this container. #### Option 4: Use Podman (Alternative) If you prefer to avoid SELinux configuration issues, consider using Podman instead of Docker, as it has better SELinux integration. ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/15-terminology/architecture.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 226, "versionNonce": 1002880859, "isDeleted": false, "id": "UczUX5VuNnCB1rVvUJVfm", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.098092529257, "y": 320.8758615860986, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 472.8823858375721, "height": 183.19688715994928, "seed": 917720693, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 161, "versionNonce": 286006267, "isDeleted": false, "id": "sKPZmBSWUdAYfBs4ByItH", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 539.5451038202509, "y": 345.2419383247636, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 82.46875, "height": 32.199999999999996, "seed": 1485551573, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Server", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Server", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 333, "versionNonce": 448586907, "isDeleted": false, "id": "_A8uznhnpXuQBYzjP-iVx", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 649.8080506852966, "y": 427.60908869342575, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 136, "height": 60, "seed": 1783625013, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "r90dckf8trHemYzEwCgCW" }, { "id": "XxfJWnHonmvNOJzMFSlie", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 298, "versionNonce": 1244067771, "isDeleted": false, "id": "r90dckf8trHemYzEwCgCW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 703.8080506852966, "y": 441.5090886934257, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 28, "height": 32.199999999999996, "seed": 660965013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113383, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "UI", "textAlign": "center", "verticalAlign": "middle", "containerId": "_A8uznhnpXuQBYzjP-iVx", "originalText": "UI", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 105, "versionNonce": 265992667, "isDeleted": false, "id": "v2eEwSOSRQBZ79O6wyzGf", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 800.9240766836483, "y": 421.4987043996123, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 135.3671503686619, "height": 62.2689029398432, "seed": 1115810805, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "svsVhxCbatcLj7lQLch0P" }, { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 83, "versionNonce": 1706870395, "isDeleted": false, "id": "svsVhxCbatcLj7lQLch0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 828.1594096804793, "y": 436.53315586953386, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 80.896484375, "height": 32.199999999999996, "seed": 2074781013, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113380, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "GRPC", "textAlign": "center", "verticalAlign": "middle", "containerId": "v2eEwSOSRQBZ79O6wyzGf", "originalText": "GRPC", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 270, "versionNonce": 418660123, "isDeleted": false, "id": "hSrrwwnm9y7R-_CnJtaK1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.567103519039, "y": 556.4146894573112, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 1983197877, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "TvtonmlV0W8__pnTG-wVZ", "type": "arrow" } ], "updated": 1697530113380, "link": null, "locked": false }, { "type": "text", "version": 154, "versionNonce": 871605179, "isDeleted": false, "id": "8tsYgVssKnBd_Zw1QuqNz", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1298.4367898442752, "y": 566.567242947784, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 96.5234375, "height": 32.199999999999996, "seed": 1321669653, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent 1", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent 1", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 182, "versionNonce": 1323136091, "isDeleted": false, "id": "eeugZg73_yD_6uLBBgmcX", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 404.5001910129067, "y": 707.1233710221009, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 210.068359375, "height": 32.199999999999996, "seed": 1901447541, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "User => Browser", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "User => Browser", "lineHeight": 1.15, "baseline": 25 }, { "type": "ellipse", "version": 106, "versionNonce": 1501835515, "isDeleted": false, "id": "mlDhl4OOc-H1tNgh77AAW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 482.5857164810477, "y": 602.4394551739279, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 46.024748503793035, "height": 44.21988070606176, "seed": 791073493, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "line", "version": 166, "versionNonce": 627726747, "isDeleted": false, "id": "ADEXzdYAhvj-_wVRftTIg", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 459.12202200277807, "y": 697.1964604319912, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 80.31792517362464, "height": 31.585599568061298, "seed": 349155381, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": null, "points": [ [ 0, 0 ], [ 42.415150610916044, -28.87829787146393 ], [ 80.31792517362464, 2.7073016965973693 ] ] }, { "type": "rectangle", "version": 231, "versionNonce": 801271355, "isDeleted": false, "id": "xmz4J-rxLIjfUQ4q19PjD", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 516.8788931508789, "y": 870.4664542146543, "strokeColor": "#f08c00", "backgroundColor": "#fff4e6", "width": 385.34512717560705, "height": 60.464035142111264, "seed": 3531157, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 93, "versionNonce": 728690395, "isDeleted": false, "id": "gSbpry_947XArfI7b6AAL", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 636.1468430141358, "y": 878.5884970070326, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 132.2890625, "height": 32.199999999999996, "seed": 1989076725, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Autoscaler", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Autoscaler", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 118, "versionNonce": 1258445691, "isDeleted": false, "id": "WVy0mdTGbUx08RuxdQUH8", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 523.3741602213286, "y": 907.372811672524, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 369.1484375, "height": 18.4, "seed": 979386453, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Starts agents based on amount of pending pipelines", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Starts agents based on amount of pending pipelines", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 373, "versionNonce": 1254044699, "isDeleted": false, "id": "0Y1RcqzVFBFqh-wy-APMI", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1232.1955835481922, "y": 605.8737363119278, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 292.6171875, "height": 18.4, "seed": 561999285, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Executes pending workflows of a pipeline", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Executes pending workflows of a pipeline", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 630, "versionNonce": 983038139, "isDeleted": false, "id": "lGumbhMs3xx1vU2632hli", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 505.62283787078286, "y": 383.42044095379515, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 158.015625, "height": 36.8, "seed": 722595605, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Central unit of a \nWoodpecker instance ", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Central unit of a \nWoodpecker instance ", "lineHeight": 1.15, "baseline": 32 }, { "type": "rectangle", "version": 131, "versionNonce": 137308507, "isDeleted": false, "id": "PbSQXehWVLYcQGXYFpd-B", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 971.7123256059622, "y": 171.06951064323448, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 274.3443117379593, "height": 74.90311522655017, "seed": 1435321461, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "Kqbwk_qfkALJfhtCIr2eS", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 96, "versionNonce": 1222067707, "isDeleted": false, "id": "2P2tz29C_2sUzVNSpaG17", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1065.5206131439782, "y": 183.12082907329545, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 73.14453125, "height": 32.199999999999996, "seed": 884403669, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Forge", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Forge", "lineHeight": 1.15, "baseline": 25 }, { "type": "text", "version": 141, "versionNonce": 1133694619, "isDeleted": false, "id": "0eYhFYPuRanZ7wkR2OlHO", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 986.864582863368, "y": 225.1223531590797, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 247.234375, "height": 18.4, "seed": 1201957685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "HK1jmIcPmM6Us6Jrynobb", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "Github, Gitea, Github, Bitbucket, ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Github, Gitea, Github, Bitbucket, ...", "lineHeight": 1.15, "baseline": 14 }, { "type": "rectangle", "version": 55, "versionNonce": 991183675, "isDeleted": false, "id": "dihpRzuIc-UoRSsOI33SZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 820.419424341303, "y": 340.29123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 247151765, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "bcUL-u4zkLA9CLG2YdaeN" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 38, "versionNonce": 2008949723, "isDeleted": false, "id": "bcUL-u4zkLA9CLG2YdaeN", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 831.853994653803, "y": 358.79123237109366, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 94.130859375, "height": 23, "seed": 1638982133, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Webhooks", "textAlign": "center", "verticalAlign": "middle", "containerId": "dihpRzuIc-UoRSsOI33SZ", "originalText": "Webhooks", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 93, "versionNonce": 295891067, "isDeleted": false, "id": "Bphhue86mMXHN4klGamM3", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 697.3018309300141, "y": 339.607928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 117, "height": 60, "seed": 92986197, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "0YxY2hEPyDWFqR8_-f6bn" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 87, "versionNonce": 2055547163, "isDeleted": false, "id": "0YxY2hEPyDWFqR8_-f6bn", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 727.4522215550141, "y": 358.107928999312, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 56.69921875, "height": 23, "seed": 43952309, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "OAuth", "textAlign": "center", "verticalAlign": "middle", "containerId": "Bphhue86mMXHN4klGamM3", "originalText": "OAuth", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 284, "versionNonce": 1205292475, "isDeleted": false, "id": "HK1jmIcPmM6Us6Jrynobb", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1205.6453201409104, "y": 250.4849674923464, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 272.1094712799886, "height": 94.31865813977868, "seed": 982632981, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "uDIWJ5K5mEBL9QaiNk3cS" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "0eYhFYPuRanZ7wkR2OlHO", "focus": -0.8418551162334328, "gap": 6.962614333266799 }, "endBinding": null, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ -69.68740859223726, 65.87860410965993 ], [ -272.1094712799886, 94.31865813977868 ] ] }, { "type": "text", "version": 53, "versionNonce": 1803962459, "isDeleted": false, "id": "uDIWJ5K5mEBL9QaiNk3cS", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1050.575099048673, "y": 297.96357160200637, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 170.765625, "height": 36.8, "seed": 1046069109, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "sends events like push, \ntag, ...", "textAlign": "center", "verticalAlign": "middle", "containerId": "HK1jmIcPmM6Us6Jrynobb", "originalText": "sends events like push, tag, ...", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 487, "versionNonce": 335895291, "isDeleted": false, "id": "Kqbwk_qfkALJfhtCIr2eS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 792.0835609101814, "y": 316.38601649373913, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 176.92139414789008, "height": 122.73778943055902, "seed": 1681656021, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yvJTQ64RU50N6-hxEQlkl" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "UczUX5VuNnCB1rVvUJVfm", "focus": -0.03867359238356983, "gap": 4.489845092359474 }, "endBinding": { "elementId": "PbSQXehWVLYcQGXYFpd-B", "focus": 0.7798878042817562, "gap": 2.707370547890605 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 60.422360349016344, -71.97786730696657 ], [ 176.92139414789008, -122.73778943055902 ] ] }, { "type": "text", "version": 62, "versionNonce": 301106427, "isDeleted": false, "id": "yvJTQ64RU50N6-hxEQlkl", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 773.7910775091977, "y": 226.00814918677256, "strokeColor": "#be4bdb", "backgroundColor": "#b2f2bb", "width": 157.4296875, "height": 36.8, "seed": 500049461, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113385, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "allows users to login \nusing existing account", "textAlign": "center", "verticalAlign": "middle", "containerId": "Kqbwk_qfkALJfhtCIr2eS", "originalText": "allows users to login using existing account", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 393, "versionNonce": 598459861, "isDeleted": false, "id": "TvtonmlV0W8__pnTG-wVZ", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 936.9267543177084, "y": 458.95033086418084, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 215.17788326846676, "height": 93.99151368376693, "seed": 234198933, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "rFf6NIofw6UBOyAFwg0Kn" } ], "updated": 1697530127259, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.30339107267010673, "gap": 1 }, "endBinding": { "elementId": "hSrrwwnm9y7R-_CnJtaK1", "focus": -0.14057158065513534, "gap": 3.4728449093634026 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 130.0760301643047, 42.90930518030268 ], [ 215.17788326846676, 93.99151368376693 ] ] }, { "type": "text", "version": 8, "versionNonce": 1693330843, "isDeleted": false, "id": "rFf6NIofw6UBOyAFwg0Kn", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 997.4942845557462, "y": 473.9409015069133, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 1592253685, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "TvtonmlV0W8__pnTG-wVZ", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 270, "versionNonce": 1855882619, "isDeleted": false, "id": "5tl702dfcvJDLz9aIFU0P", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 886.0581619083632, "y": 485.67004123832135, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 174.09447592006472, "height": 326.4905563076211, "seed": 1479177813, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "apyMCAv2GIN_yzHXwX4tY" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "v2eEwSOSRQBZ79O6wyzGf", "focus": -0.1341191028023529, "gap": 1.9024338988657519 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "focus": -0.7088661407505865, "gap": 4.060573862784622 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 44.14165353942735, 196.18483635907205 ], [ 174.09447592006472, 326.4905563076211 ] ] }, { "type": "text", "version": 66, "versionNonce": 2007745083, "isDeleted": false, "id": "apyMCAv2GIN_yzHXwX4tY", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 849.4927841977906, "y": 663.4548775973934, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 161.4140625, "height": 36.8, "seed": 882041781, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113386, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "receives workflows & \nreturns logs + statuses", "textAlign": "center", "verticalAlign": "middle", "containerId": "5tl702dfcvJDLz9aIFU0P", "originalText": "receives workflows & returns logs + statuses", "lineHeight": 1.15, "baseline": 32 }, { "type": "arrow", "version": 347, "versionNonce": 1353818811, "isDeleted": false, "id": "XxfJWnHonmvNOJzMFSlie", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 534.9278465333664, "y": 595.2199151317081, "strokeColor": "#c2255c", "backgroundColor": "transparent", "width": 113.88020415193023, "height": 119.81968366814112, "seed": 944153877, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "_A8uznhnpXuQBYzjP-iVx", "focus": 0.5397285671082249, "gap": 1 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 113.88020415193023, -119.81968366814112 ] ] }, { "type": "rectangle", "version": 61, "versionNonce": 1099141979, "isDeleted": false, "id": "j56ZKRwmXk72nHrZzLz_1", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1081.8110514012087, "y": 652.5253283508498, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 566.7373014532342, "height": 68.58600908319681, "seed": 112933493, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false }, { "type": "text", "version": 82, "versionNonce": 1879994363, "isDeleted": false, "id": "cAVYXfBRnfuGAv7QTQVow", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1300.6584159706863, "y": 658.8425033454967, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 77.83203125, "height": 23, "seed": 951460821, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Backend", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Backend", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 376,- add some images explaining the architecture & terminology with pipeline -> workflow -> step - combine advanced config usage - rename pipeline syntax to workflow syntax (and most references to pipeline steps etc as well) - update agent registration part - add bug note to secrets encryption setting - remove usage from readme to point to up-to-date docs page - typos - closes #1408 --------- "angle": 0, "x": 1094.1972977313717, "y": 681.8988272758752, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 530.9453125, "height": 55.199999999999996, "seed": 843899189, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The backend is the environment (exp. Docker / Kubernetes / local) used to \nexecute workflows in.\n", "lineHeight": 1.15, "baseline": 50 }, { "type": "rectangle", "version": 384, "versionNonce": 1778969915, "isDeleted": false, "id": "pxF49EKDNO6IZq_34i7bY", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1064.2132116912126, "y": 754.5018564383092, "strokeColor": "#2f9e44", "backgroundColor": "#ebfbee", "width": 601.932705468054, "height": 175.07489600604117, "seed": 954528405, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "05EJzh4NLXxemaKAmdi5n", "type": "arrow" }, { "id": "5tl702dfcvJDLz9aIFU0P", "type": "arrow" } ], "updated": 1697530113381, "link": null, "locked": false }, { "type": "arrow", "version": 154, "versionNonce": 1988988379, "isDeleted": false, "id": "05EJzh4NLXxemaKAmdi5n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 904.0288881242177, "y": 882.4966027880746, "strokeColor": "#f08c00", "backgroundColor": "transparent", "width": 158.83070714434325, "height": 32.735025983189644, "seed": 1228134389, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [ { "type": "text", "id": "yNxAOEPZu_Jl7mnI01OXs" } ], "updated": 1697530113381, "link": null, "locked": false, "startBinding": { "elementId": "xmz4J-rxLIjfUQ4q19PjD", "gap": 1.8048677977312764, "focus": 0.31250963573550006 }, "endBinding": { "elementId": "pxF49EKDNO6IZq_34i7bY", "gap": 1.353616422651612, "focus": 0.36496042109885213 }, "lastCommittedPoint": null, "startArrowhead": null, "endArrowhead": "triangle", "points": [ [ 0, 0 ], [ 158.83070714434325, -32.735025983189644 ] ] }, { "type": "text", "version": 25, "versionNonce": 1393410779, "isDeleted": false, "id": "yNxAOEPZu_Jl7mnI01OXs", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 963.8856479463893, "y": 856.9290897964797, "strokeColor": "#f08c00", "backgroundColor": "#b2f2bb", "width": 39.1171875, "height": 18.4, "seed": 759107925, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113387, "link": null, "locked": false, "fontSize": 16, "fontFamily": 2, "text": "starts", "textAlign": "center", "verticalAlign": "middle", "containerId": "05EJzh4NLXxemaKAmdi5n", "originalText": "starts", "lineHeight": 1.15, "baseline": 14 }, { "type": "text", "version": 187, "versionNonce": 671224603, "isDeleted": false, "id": "sSj4Pda-fo-BBYM_dzml6", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 1296.0854928322988, "y": 776.6118140041631, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 104.2890625, "height": 32.199999999999996, "seed": 1381768885, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530113381, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Agent ...", "textAlign": "right", "verticalAlign": "top", "containerId": null, "originalText": "Agent ...", "lineHeight": 1.15, "baseline": 25 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/15-terminology/index.md ================================================ # Terminology ## Glossary - **Agent**: A component of Woodpecker that executes [pipelines][Pipeline] (specifically one or more [workflows][Workflow]) with a specific backend (e.g. [Docker][], Kubernetes, [local][Local]). It connects to the server via GRPC. - **CLI**: The Woodpecker command-line interface (CLI) is a terminal tool used to administer the server, to execute pipelines locally for debugging / testing purposes, and to perform tasks like linting pipelines. - **Code**: Refers to the files tracked by the version control system used by the [forge][Forge]. - **Commit**: A defined state of the code, usually associated with a version control system like Git. - **Container**: A lightweight and isolated environment where commands are executed. - **Dependency**: [Workflows][Workflow] can depend on each other, and if possible, they are executed in parallel. - **[Event][Event]**: Triggers the execution of a [pipeline][Pipeline], such as a [forge][Forge] event like `push`, or `manual` triggered manually from the UI. - **[Extension][Extension]**: Some parts of Woodpecker internal services like secrets storage or config fetcher can be replaced through extensions. - **[Forge][Forge]**: The hosting platform or service where the repositories are hosted. - **[Matrix][Matrix]**: A configuration option that allows the execution of [workflows][Workflow] for each value in the matrix. - **[Pipeline][Pipeline]**: A sequence of [workflows][Workflow] that are executed on the code. Pipelines are triggered by events. - **[Plugins][Plugin]**: Plugins are extensions that provide pre-defined actions or commands for a step in a [workflow][Workflow]. They can be configured via settings. - **Repos**: Short for repositories, these are storage locations where code is stored. - **Server**: The component of Woodpecker that handles webhooks from forges, orchestrates agents, and sends status back. It also serves the API and web UI for administration and configuration. - **Service**: A service is a step that is executed from the start of a [workflow][Workflow] until its end. It can be accessed by name via the network from other steps within the same [workflow][Workflow]. - **Status**: Status refers to the outcome of a step or [workflow][Workflow] after it has been executed, determined by the internal command exit code. At the end of a [workflow][Workflow], its status is sent to the [forge][Forge]. - **Steps**: Individual commands, actions or tasks within a [workflow][Workflow]. - **Task**: A task is a [workflow][Workflow] that's currently waiting for its execution in the task queue. - **Woodpecker**: An open-source tool that executes [pipelines][Pipeline] on your code. - **Woodpecker CI**: The project name around Woodpecker. - **[Workflow][Workflow]**: A sequence of steps and services that are executed as part of a [pipeline][Pipeline]. Workflows are represented by YAML files. Each workflow has its own isolated [workspace][Workspace], and often additional resources like a shared network (docker). - **[Workspace][workspace]**: A folder shared between all steps of a [workflow][Workflow] containing the repository and all the generated data from previous steps. - **YAML File**: A file format used to define and configure [workflows][Workflow]. ## Woodpecker architecture ![Woodpecker architecture](architecture.svg) ## Pipeline, workflow & step ![Relation between pipelines, workflows and steps](pipeline-workflow-step.svg) ## Conventions Sometimes there are multiple terms that can be used to describe something. This section lists the preferred terms to use in Woodpecker: - Environment variables `*_LINK` should be called `*_URL`. In the code use `URL()` instead of `Link()` - Use the term **pipelines** instead of the previous **builds** - Use the term **steps** instead of the previous **jobs** - Use the prefix `WOODPECKER_EXPERT_` for advanced environment variables that are normally not required to be set by users [Event]: ../20-workflow-syntax.md#event [Pipeline]: ../20-workflow-syntax.md [Workflow]: ../25-workflows.md [Forge]: ../../30-administration/10-configuration/12-forges/11-overview.md [Plugin]: ../51-plugins/51-overview.md [Workspace]: ../20-workflow-syntax.md#workspace [Matrix]: ../30-matrix-workflows.md [Docker]: ../../30-administration/10-configuration/11-backends/10-docker.md [Local]: ../../30-administration/10-configuration/11-backends/30-local.md [Extension]: ../72-extensions/index.md ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/15-terminology/pipeline-workflow-step.excalidraw ================================================ { "type": "excalidraw", "version": 2, "source": "https://excalidraw.com", "elements": [ { "type": "rectangle", "version": 97, "versionNonce": 257762037, "isDeleted": false, "id": "Y3hYdpX9r1qWfyHWs7AXT", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 393.622323134362, "y": 336.02197155458475, "strokeColor": "#1971c2", "backgroundColor": "#e7f5ff", "width": 366.3936710429598, "height": 499.95605689083004, "seed": 875444373, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 67, "versionNonce": 369556565, "isDeleted": false, "id": "g1Eb010Kx_KFryVqNYWBQ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 520.0116988873679, "y": 363.32095846456355, "strokeColor": "#1971c2", "backgroundColor": "#b2f2bb", "width": 99.626953125, "height": 32.199999999999996, "seed": 1466195445, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 28, "fontFamily": 2, "text": "Pipeline", "textAlign": "center", "verticalAlign": "top", "containerId": null, "originalText": "Pipeline", "lineHeight": 1.15, "baseline": 25 }, { "type": "rectangle", "version": 314, "versionNonce": 1983028731, "isDeleted": false, "id": "9o-DNP0YdlIGVz1kEm_hW", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 407.1590381712276, "y": 410.9252244837219, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1869535061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" }, { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083624, "link": null, "locked": false }, { "type": "rectangle", "version": 156, "versionNonce": 1495247317, "isDeleted": false, "id": "q4TKpiq2KAwPaz19GdhtK", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 490.3194993196821, "y": 473.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 111355061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "ya0JzDo-4oscHIq87TZ_D" }, { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" }, { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 156, "versionNonce": 1469425461, "isDeleted": false, "id": "ya0JzDo-4oscHIq87TZ_D", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 566.0118821321821, "y": 478.52959018719525, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1084671509, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "q4TKpiq2KAwPaz19GdhtK", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 236, "versionNonce": 1535319541, "isDeleted": false, "id": "AOJLQFldoHd2vxVtB2jrS", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 491.2218643672577, "y": 519.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 812596085, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "FRby8A9aUiKvHpM5mCdDN" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 231, "versionNonce": 28677973, "isDeleted": false, "id": "FRby8A9aUiKvHpM5mCdDN", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 583.0324112422577, "y": 524.7800332298218, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1849820373, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "AOJLQFldoHd2vxVtB2jrS", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 291, "versionNonce": 571598005, "isDeleted": false, "id": "2WwuMWX7YawqK0i1rDPJo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 489.6426911083554, "y": 567.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1840554549, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "UOwxmKIS0W62CFt_ffEy4" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 289, "versionNonce": 4032021, "isDeleted": false, "id": "UOwxmKIS0W62CFt_ffEy4", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 581.4532379833554, "y": 572.609787233933, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 330077077, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "2WwuMWX7YawqK0i1rDPJo", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 296, "versionNonce": 1539516059, "isDeleted": false, "id": "9laL3864YWOna6NQlVDqq", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 630.0635849044402, "y": 383.14314287821776, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 294.3024370154917, "height": 36.656016722015465, "seed": 207575285, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -1.000156025347643, "gap": 27.782081605504118 }, "endBinding": { "elementId": "vS2PNUbmeBe3EPxl-dID8", "focus": 0.7761987167055517, "gap": 8.978940924346716 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 294.3024370154917, -36.656016722015465 ] ] }, { "type": "text", "version": 249, "versionNonce": 2076402229, "isDeleted": false, "id": "vS2PNUbmeBe3EPxl-dID8", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 933.3449628442786, "y": 336.02200598023114, "strokeColor": "#1971c2", "backgroundColor": "transparent", "width": 301.298828125, "height": 46, "seed": 1632793173, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "9laL3864YWOna6NQlVDqq", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "A pipeline is triggered by an event\nlike a push, tag, manual", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "A pipeline is triggered by an event\nlike a push, tag, manual", "lineHeight": 1.15, "baseline": 41 }, { "type": "arrow", "version": 751, "versionNonce": 1371044827, "isDeleted": false, "id": "FU4jk6Tz6duLaaZE0Z55A", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 751.1619011845514, "y": 440.8355079324799, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 160.46519124360202, "height": 2.2452348338335923, "seed": 1331388341, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "9o-DNP0YdlIGVz1kEm_hW", "focus": -0.6591700594229558, "gap": 3.8807513696519322 }, "endBinding": { "elementId": "wfFvnFZuh0npL9hh0ez7o", "focus": 0.7652411053273549, "gap": 20.75618622779257 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 160.46519124360202, -2.2452348338335923 ] ] }, { "type": "rectangle", "version": 440, "versionNonce": 819540565, "isDeleted": false, "id": "TbejdIYo_qNDw15yLP2IB", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 406.0812257713851, "y": 626.8305540252475, "strokeColor": "#be4bdb", "backgroundColor": "#f8f0fc", "width": 340.12211164367193, "height": 199, "seed": 1553965333, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 466, "versionNonce": 663477, "isDeleted": false, "id": "wfFvnFZuh0npL9hh0ez7o", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 932.383278655946, "y": 424.0107569968011, "strokeColor": "#be4bdb", "backgroundColor": "transparent", "width": 481.2890625, "height": 115, "seed": 781497973, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "FU4jk6Tz6duLaaZE0Z55A", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Every pipeline consists of multiple workflows.\nEach defined by a separate YAML file and is named \nafter the filename.\nEach workflow has its own workspace (folder) which is\nused by all steps of that workflow.", "lineHeight": 1.15, "baseline": 110 }, { "type": "arrow", "version": 464, "versionNonce": 734626075, "isDeleted": false, "id": "1ZbDRqbETCkEx62nCmnpJ", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 741.0645380446722, "y": 492.31283255558515, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 178.4459423531871, "height": 83.08707392565111, "seed": 536879061, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "q4TKpiq2KAwPaz19GdhtK", "focus": -0.7697471991854113, "gap": 3.7450387249900814 }, "endBinding": { "elementId": "Vu0JJ6ZWuEhEyCfxeHPtc", "focus": -0.7822252364700005, "gap": 8.360835317635974 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 178.4459423531871, 83.08707392565111 ] ] }, { "type": "text", "version": 327, "versionNonce": 371646421, "isDeleted": false, "id": "Vu0JJ6ZWuEhEyCfxeHPtc", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 927.8713157154953, "y": 563.2132686484658, "strokeColor": "#2f9e44", "backgroundColor": "transparent", "width": 491.357421875, "height": 46, "seed": 385310005, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "1ZbDRqbETCkEx62nCmnpJ", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "The default first step of each workflow is the clone step.\nIts fetches the specific code version for a pipeline.", "lineHeight": 1.15, "baseline": 41 }, { "type": "text", "version": 91, "versionNonce": 1180085909, "isDeleted": false, "id": "0tGx2VdJLNf7W6HD76dtO", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 427.6895298601876, "y": 432.3583566254258, "strokeColor": "#9c36b5", "backgroundColor": "#a5d8ff", "width": 143.876953125, "height": 23, "seed": 450883221, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"build\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"build\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 338, "versionNonce": 957223925, "isDeleted": false, "id": "LQ2h2aO9uzDWyLG6OLn70", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.7251825950889, "y": 685.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 711939061, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "8EqaPnZX2CgLaF08UNZZg" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 340, "versionNonce": 510774613, "isDeleted": false, "id": "8EqaPnZX2CgLaF08UNZZg", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 563.4175654075889, "y": 690.3516128043414, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 95.615234375, "height": 23, "seed": 1370164565, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Clone step", "textAlign": "center", "verticalAlign": "middle", "containerId": "LQ2h2aO9uzDWyLG6OLn70", "originalText": "Clone step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 421, "versionNonce": 97999541, "isDeleted": false, "id": "St9t4nwHuXXVlmjDqfn_Z", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 488.62754764266447, "y": 731.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 2145950389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "DX10t075MMDu7BLtuUaij" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 417, "versionNonce": 2011446293, "isDeleted": false, "id": "DX10t075MMDu7BLtuUaij", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 580.4380945176645, "y": 736.6020558469675, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 500005909, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "1. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "St9t4nwHuXXVlmjDqfn_Z", "originalText": "1. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "rectangle", "version": 475, "versionNonce": 1284370805, "isDeleted": false, "id": "XVGBz_X5yN6xjWTosVH2n", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 487.04837438376217, "y": 779.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 247, "height": 33, "seed": 1666134389, "groupIds": [], "frameId": null, "roundness": { "type": 3 }, "boundElements": [ { "type": "text", "id": "-xogFSFcP-Vv5cuOSFm8T" } ], "updated": 1697530083427, "link": null, "locked": false }, { "type": "text", "version": 476, "versionNonce": 1092221653, "isDeleted": false, "id": "-xogFSFcP-Vv5cuOSFm8T", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 578.8589212587622, "y": 784.4318098510787, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 63.37890625, "height": 23, "seed": 1840462549, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "2. Step", "textAlign": "center", "verticalAlign": "middle", "containerId": "XVGBz_X5yN6xjWTosVH2n", "originalText": "2. Step", "lineHeight": 1.15, "baseline": 18 }, { "type": "text", "version": 125, "versionNonce": 1310578741, "isDeleted": false, "id": "N1a9yL7Pts16hUKY9-vhw", "fillStyle": "hachure", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 424.78852030984035, "y": 646.2446482189896, "strokeColor": "#be4bdb", "backgroundColor": "#a5d8ff", "width": 133.857421875, "height": 23, "seed": 361699381, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Workflow \"test\"", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Workflow \"test\"", "lineHeight": 1.15, "baseline": 18 }, { "type": "arrow", "version": 184, "versionNonce": 2127603131, "isDeleted": false, "id": "O-YmtRLb8uFNqCAz22EoG", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 737.454940151797, "y": 535.9141784615474, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 190.41665096887027, "height": 112.96427727851824, "seed": 80234901, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": null, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.8392895251910331, "gap": 2.0300115262207328 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 190.41665096887027, 112.96427727851824 ] ] }, { "type": "arrow", "version": 327, "versionNonce": 780710651, "isDeleted": false, "id": "379hO6Dc5rygB38JgDbVo", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 738.8084877231549, "y": 591.3526691276127, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 186.8066399682357, "height": 57.68023784868956, "seed": 211046133, "groupIds": [], "frameId": null, "roundness": { "type": 2 }, "boundElements": [], "updated": 1697530083624, "link": null, "locked": false, "startBinding": { "elementId": "2WwuMWX7YawqK0i1rDPJo", "focus": -0.5776522830934517, "gap": 2.1657966147995467 }, "endBinding": { "elementId": "0TjxOfERekC91N3yciQIq", "focus": -0.7269489945238884, "gap": 4.286474955497397 }, "lastCommittedPoint": null, "startArrowhead": "triangle", "endArrowhead": null, "points": [ [ 0, 0 ], [ 186.8066399682357, 57.68023784868956 ] ] }, { "type": "text", "version": 285, "versionNonce": 1165977685, "isDeleted": false, "id": "0TjxOfERekC91N3yciQIq", "fillStyle": "solid", "strokeWidth": 4, "strokeStyle": "solid", "roughness": 0, "opacity": 100, "angle": 0, "x": 929.901602646888, "y": 632.4760859429873, "strokeColor": "#2f9e44", "backgroundColor": "#b2f2bb", "width": 518.076171875, "height": 46, "seed": 997763157, "groupIds": [], "frameId": null, "roundness": null, "boundElements": [ { "id": "O-YmtRLb8uFNqCAz22EoG", "type": "arrow" }, { "id": "379hO6Dc5rygB38JgDbVo", "type": "arrow" } ], "updated": 1697530083427, "link": null, "locked": false, "fontSize": 20, "fontFamily": 2, "text": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "textAlign": "left", "verticalAlign": "top", "containerId": null, "originalText": "Additional steps are used to execute commands or plugins\nlike `make install` or release-to-github", "lineHeight": 1.15, "baseline": 41 } ], "appState": { "gridSize": null, "viewBackgroundColor": "#ffffff" }, "files": {} } ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/20-workflow-syntax.md ================================================ # Workflow syntax The Workflow section defines a list of steps to build, test and deploy your code. The steps are executed serially in the order in which they are defined. If a step returns a non-zero exit code, the workflow and therefore the entire pipeline terminates immediately and returns an error status. :::note An exception to this rule are steps with a [`status: [failure]`](#status) condition, which ensures that they are executed in the case of a failed run. ::: :::note We support most of YAML 1.2, but preserve some behavior from 1.1 for backward compatibility. Read more at: [https://github.com/go-yaml/yaml](https://github.com/go-yaml/yaml/tree/v3) ::: Example steps: ```yaml steps: - name: backend image: golang commands: - go build - go test - name: frontend image: node commands: - npm install - npm run test - npm run build ``` In the above example we define two steps, `frontend` and `backend`. The names of these steps are completely arbitrary. The name is optional, if not added the steps will be numerated. Another way to name a step is by using dictionaries: ```yaml steps: backend: image: golang commands: - go build - go test frontend: image: node commands: - npm install - npm run test - npm run build ``` ## Skip Commits Woodpecker gives the ability to skip individual commits by adding `[SKIP CI]` or `[CI SKIP]` to the commit message. Note this is case-insensitive. ```bash git commit -m "updated README [CI SKIP]" ``` ## Steps Every step of your workflow executes commands inside a specified container.
The defined steps are executed in sequence by default, if they should run in parallel you can use [`depends_on`](./20-workflow-syntax.md#depends_on).
The associated commit is checked out with git to a workspace which is mounted to every step of the workflow as the working directory. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` ### File changes are incremental - Woodpecker clones the source code in the beginning of the workflow - Changes to files are persisted through steps as the same volume is mounted to all steps ```yaml title=".woodpecker.yaml" steps: - name: build image: debian commands: - echo "test content" > myfile - name: a-test-step image: debian commands: - cat myfile ``` ### `image` Woodpecker pulls the defined image and uses it as environment to execute the workflow step commands, for plugins and for service containers. When using the `local` backend, the `image` entry is used to specify the shell, such as Bash or Fish, that is used to run the commands. ```diff steps: - name: build + image: golang:1.6 commands: - go build - go test - name: prettier + image: woodpeckerci/plugin-prettier services: - name: database + image: mysql ``` Woodpecker supports any valid Docker image from any Docker registry: ```yaml image: golang image: golang:1.7 image: library/golang:1.7 image: index.docker.io/library/golang image: index.docker.io/library/golang:1.7 ``` Learn more how you can use images from [different registries](./41-registries.md). ### `pull` By default, Woodpecker does not automatically upgrade container images and only pulls them when they are not already present. To always pull the latest image when updates are available, use the `pull` option: ```diff steps: - name: build image: golang:latest + pull: true ``` ### `commands` Commands of every step are executed serially as if you would enter them into your local shell. ```diff steps: - name: backend image: golang commands: + - go build + - go test ``` There is no magic here. The above commands are converted to a simple shell script. The commands in the above example are roughly converted to the below script: ```bash #!/bin/sh set -e go build go test ``` The above shell script is then executed as the container entrypoint. The below docker command is an (incomplete) example of how the script is executed: ```bash docker run --entrypoint=build.sh golang ``` :::note Only build steps can define commands. You cannot use commands with plugins or services. ::: ### `entrypoint` Allows you to specify the entrypoint for containers. Note that this must be a list of the command and its arguments (e.g. `["/bin/sh", "-c"]`). If you define [`commands`](#commands), the default entrypoint will be `["/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"]`. You can also use a custom shell with `CI_SCRIPT` (Base64-encoded) if you set `commands`. ### `environment` Woodpecker provides the ability to pass environment variables to individual steps. For more details, check the [environment docs](./50-environment.md). ### `failure` Some of the steps may be allowed to fail without causing the whole workflow and therefore pipeline to report a failure (e.g., a step executing a linting check). To enable this, add `failure: ignore` to your step. If Woodpecker encounters an error while executing the step, it will report it as failed but still executes the next steps of the workflow, if any, without affecting the status of the workflow. ```diff steps: - name: backend image: golang commands: - go build - go test + failure: ignore ``` If you would like to cancel the full pipeline once the step fails, you can set `failure: cancel`. ### `when` - Conditional Execution Woodpecker supports defining a list of conditions for a step by using a `when` block. If at least one of the conditions in the `when` block evaluate to true the step is executed, otherwise it is skipped. A condition is evaluated to true if _all_ sub-conditions are true. A condition can be a check like: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - event: pull_request + repo: test/test + - event: push + branch: main ``` The `prettier` step is executed if one of these conditions is met: 1. The pipeline is executed from a pull request in the repo `test/test` 2. The pipeline is executed from a push to `main` #### `repo` Example conditional execution by repository: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - repo: test/test ``` #### `branch` :::note Branch conditions are not applied to tags. ::: Example conditional execution by branch: ```diff steps: - name: prettier image: woodpeckerci/plugin-prettier + when: + - branch: main ``` > The step now triggers on main branch, but also if the target branch of a pull request is `main`. Add an event condition to limit it further to pushes on main only. Execute a step if the branch is `main` or `develop`: ```yaml when: - branch: [main, develop] ``` Execute a step if the branch starts with `prefix/*`: ```yaml when: - branch: prefix/* ``` The branch matching is done using [doublestar](https://github.com/bmatcuk/doublestar/#usage), note that a pattern starting with `*` should be put between quotes and a literal `/` needs to be escaped. A few examples: - `*\\/*` to match patterns with exactly 1 `/` - `*\\/**` to match patters with at least 1 `/` - `*` to match patterns without `/` - `**` to match everything Execute a step using custom include and exclude logic: ```yaml when: - branch: include: [main, release/*] exclude: [release/1.0.0, release/1.1.*] ``` #### `event` The available events are: - `push`: triggered when a commit is pushed to a branch. - `pull_request`: triggered when a pull request is opened or a new commit is pushed to it. - `pull_request_closed`: triggered when a pull request is closed or merged. - `pull_request_metadata`: triggered when a pull request metadata has changed (e.g. title, body, label, milestone, ...). - `tag`: triggered when a tag is pushed. - `release`: triggered when a release, pre-release or draft is created. (You can apply further filters using [evaluate](#evaluate) with [environment variables](./50-environment.md#built-in-environment-variables).) - `deployment`: triggered when a deployment is created in the repository. (This event can be triggered from Woodpecker directly. GitHub also supports webhook triggers.) - `cron`: triggered when a cron job is executed. - `manual`: triggered when a user manually triggers a pipeline. Execute a step if the build event is a `tag`: ```yaml when: - event: tag ``` Execute a step if the pipeline event is a `push` to a specified branch: ```diff when: - event: push + branch: main ``` Execute a step for multiple events: ```yaml when: - event: [push, tag, deployment] ``` #### `cron` This filter **only** applies to cron events and filters based on the name of a cron job. Make sure to have a `event: cron` condition in the `when`-filters as well. ```yaml when: - event: cron cron: sync_* # name of your cron job ``` [Read more about cron](./45-cron.md) #### `ref` The `ref` filter compares the git reference against which the workflow is executed. This allows you to filter, for example, tags that must start with **v**: ```yaml when: - event: tag ref: refs/tags/v* ``` #### `status` By default, steps only run when the workflow has succeeded up to that point,
which is equivalent to `status: [ success ]`. The `status` filter lets you override this behavior. The only accepted values are `success` and `failure`. A common use case is executing a step on failure, such as sending notifications for a failed workflow/pipeline. To run a step regardless of outcome, list both values: ```diff steps: - name: notify image: alpine + when: + - status: [ success, failure ] ``` The filter is aware of the other filters. If you want to run on failures if the event is `tag`, but if it's a `pull_request`, run it on both success and failure: ```diff when: + - event: tag + status: [ failure ] + - event: pull_request + status: [ success, failure ] ``` If there's no matching filter at all or all matching filters don't have set `status`, it will use the default, which means it runs on success only. In the example above this will happen if the event is neither `tag` nor `pull_request`. #### `platform` :::note This condition should be used in conjunction with a [matrix](./30-matrix-workflows.md#example-matrix-pipeline-using-multiple-platforms) workflow as a regular workflow will only be executed by a single agent which only has one arch. ::: Execute a step for a specific platform: ```yaml when: - platform: linux/amd64 ``` Execute a step for a specific platform using wildcards: ```yaml when: - platform: [linux/*, windows/amd64] ``` #### `matrix` Execute a step for a single matrix permutation: ```yaml when: - matrix: GO_VERSION: 1.5 REDIS_VERSION: 2.8 ``` #### `instance` Execute a step only on a certain Woodpecker instance matching the specified hostname: ```yaml when: - instance: stage.woodpecker.company.com ``` #### `path` :::info Path conditions are applied only to **push** and **pull_request** events. ::: Execute a step only on a pipeline with certain files being changed: ```yaml when: - path: 'src/*' ``` You can use [glob patterns](https://github.com/bmatcuk/doublestar#patterns) to match the changed files and specify if the step should run if a file matching that pattern has been changed `include` or if some files have **not** been changed `exclude`. For pipelines without file changes (empty commits or on events without file changes like `tag`), you can use `on_empty` to set whether this condition should be **true** _(default)_ or **false** in these cases. ```yaml when: - path: include: ['.woodpecker/*.yaml', '*.ini'] exclude: ['*.md', 'docs/**'] ignore_message: '[ALL]' on_empty: true ``` :::info Passing a defined ignore-message like `[ALL]` inside the commit message will ignore all path conditions and the `on_empty` setting. ::: #### `evaluate` Execute a step only if the provided evaluate expression is equal to true. Both built-in [`CI_`](./50-environment.md#built-in-environment-variables) and custom variables can be used inside the expression. The expression syntax can be found in [the docs](https://github.com/expr-lang/expr/blob/master/docs/language-definition.md) of the underlying library. Run on pushes to the default branch for the repository `owner/repo`: ```yaml when: - evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo" && CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' ``` Run on commits created by user `woodpecker-ci`: ```yaml when: - evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' ``` Skip all commits containing `please ignore me` in the commit message: ```yaml when: - evaluate: 'not (CI_COMMIT_MESSAGE contains "please ignore me")' ``` Run on pull requests with the label `deploy`: ```yaml when: - evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "deploy"' ``` Skip step only if `SKIP=true`, run otherwise or if undefined: ```yaml when: - evaluate: 'SKIP != "true"' ``` ### `depends_on` Normally steps of a workflow are executed serially in the order in which they are defined. As soon as you set `depends_on` for a step a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) will be used and all steps of the workflow will be executed in parallel besides the steps that have a dependency set to another step using `depends_on`: ```diff steps: - name: build # build will be executed immediately image: golang commands: - go build - name: deploy image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file + depends_on: [build, test] # deploy will be executed after build and test finished - name: test # test will be executed immediately as no dependencies are set image: golang commands: - go test ``` :::note You can define a step to start immediately without dependencies by adding an empty `depends_on: []`. By setting `depends_on` on a single step all other steps will be immediately executed as well if no further dependencies are specified. ```yaml steps: - name: check code format image: mstruebing/editorconfig-checker depends_on: [] # enable parallel steps ... ``` ::: ### `volumes` Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. For more details check the [volumes docs](./70-volumes.md). ### `detach` Woodpecker gives the ability to detach steps to run them in background until the workflow finishes. For more details check the [service docs](./60-services.md#detachment). ### `directory` Using `directory`, you can set a subdirectory of your repository or an absolute path inside the Docker container in which your commands will run. ### `backend_options` With `backend_options` you can define options that are specific to the respective backend that is used to execute the steps. For example, you can specify the user and/or group used in a Docker container or you can specify the service account for Kubernetes. Further details can be found in the documentation of the used backend: - [Docker](../30-administration/10-configuration/11-backends/10-docker.md#step-specific-configuration) - [Kubernetes](../30-administration/10-configuration/11-backends/20-kubernetes.md#step-specific-configuration) ## `services` Woodpecker can provide service containers. They can for example be used to run databases or cache containers during the execution of workflow. For more details check the [services docs](./60-services.md). ## `workspace` The workspace defines the shared volume and working directory shared by all workflow steps. The default workspace base is `/woodpecker` and the path is extended with the repository URL (`src/{url-without-schema}`). So an example would be `/woodpecker/src/github.com/octocat/hello-world`. The workspace can be customized using the workspace block in the YAML file: ```diff +workspace: + base: /go + path: src/github.com/octocat/hello-world steps: - name: build image: golang:latest commands: - go get - go test ``` :::note Plugins will always have the workspace base at `/woodpecker` ::: The base attribute defines a shared base volume available to all steps. This ensures your source code, dependencies and compiled binaries are persisted and shared between steps. ```diff workspace: + base: /go path: src/github.com/octocat/hello-world steps: - name: deps image: golang:latest commands: - go get - go test - name: build image: node:latest commands: - go build ``` This would be equivalent to the following docker commands: ```bash docker volume create my-named-volume docker run --volume=my-named-volume:/go golang:latest docker run --volume=my-named-volume:/go node:latest ``` The path attribute defines the working directory of your build. This is where your code is cloned and will be the default working directory of every step in your build process. The path must be relative and is combined with your base path. ```diff workspace: base: /go + path: src/github.com/octocat/hello-world ``` ```bash git clone https://github.com/octocat/hello-world \ /go/src/github.com/octocat/hello-world ``` ## `matrix` Woodpecker has integrated support for matrix builds. Woodpecker executes a separate build task for each combination in the matrix, allowing you to build and test a single commit against multiple configurations. For more details check the [matrix build docs](./30-matrix-workflows.md). ## `labels` You can define labels for your workflow in order to select an agent to execute the workflow. An agent takes up a workflow and executes it if **every** label assigned to it matches the label of the agent. To specify additional agent labels, check the [Agent configuration options](../30-administration/10-configuration/30-agent.md#agent_labels). The agents have at least four default labels: `platform=agent-os/agent-arch`, `hostname=my-agent`, `backend=docker` (type of agent backend) and `repo=*`. Agents can use an `*` as a placeholder for a label. For example, `repo=*` matches any repo. Workflow labels with an empty value are ignored. By default, each workflow has at least the label `repo=your-user/your-repo-name`. If you have set the [platform attribute](#platform) for your workflow, it will also have a label such as `platform=your-os/your-arch`. :::warning Labels with the `woodpecker-ci.org` prefix are managed by Woodpecker and can not be set as part of the pipeline definition. ::: You can add additional labels as a key value map: ```diff +labels: + location: europe # only agents with `location=europe` or `location=*` will be used + weather: sun + hostname: "" # this label will be ignored as it is empty steps: - name: build image: golang commands: - go build - go test ``` ### Filter by platform To configure your workflow to only be executed on an agent with a specific platform, you can use the `platform` key. Have a look at the official [go docs](https://go.dev/doc/install/source) for the available platforms. The syntax of the platform is `GOOS/GOARCH` like `linux/arm64` or `linux/amd64`. Example: Assuming we have two agents, one `linux/arm` and one `linux/amd64`. Previously this workflow would have executed on **either agent**, as Woodpecker is not fussy about where it runs the workflows. By setting the following option it will only be executed on an agent with the platform `linux/arm64`. ```diff +labels: + platform: linux/arm64 steps: [...] ``` ## `variables` Woodpecker supports using [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in the workflow configuration. For more details and examples check the [Advanced usage docs](./90-advanced-usage.md) ## `clone` Woodpecker automatically configures a default clone step if it is not explicitly defined. If you are using the `local` backend, the [plugin-git](https://github.com/woodpecker-ci/plugin-git) binary must be in your `$PATH` for the default clone step to work. If this is not the case, you can still write a manual clone step. You can manually configure the clone step in your workflow to customize it: ```diff +clone: + git: + image: woodpeckerci/plugin-git steps: - name: build image: golang commands: - go build - go test ``` Example configuration to override the depth: ```diff clone: - name: git image: woodpeckerci/plugin-git + settings: + partial: false + depth: 50 ``` Example configuration to use a custom clone plugin: ```diff clone: - name: git + image: octocat/custom-git-plugin ``` ### Git Submodules To use the credentials used to clone the repository to clone its submodules, update `.gitmodules` to use `https` instead of `git`: ```diff [submodule "my-module"] path = my-module -url = git@github.com:octocat/my-module.git +url = https://github.com/octocat/my-module.git ``` To use the ssh git url in `.gitmodules` for users cloning with ssh, and also use the https url in Woodpecker, add `submodule_override`: ```diff clone: - name: git image: woodpeckerci/plugin-git settings: recursive: true + submodule_override: + my-module: https://github.com/octocat/my-module.git steps: ... ``` ## `skip_clone` :::warning The default clone step is executed as `root` to ensure that the workspace directory can be accessed by any user (`0777`). This is necessary to allow rootless step containers to write to the workspace directory. If a rootless step container is used with `skip_clone`, the user must ensure a suitable workspace directory that can be accessed by the unprivileged container use, e.g. `/tmp`. ::: By default Woodpecker is automatically adding a clone step. This clone step can be configured by the [clone](#clone) property. If you do not need a `clone` step at all you can skip it using: ```yaml skip_clone: true ``` ## `when` - Global workflow conditions Woodpecker gives the ability to skip whole workflows ([not just steps](#when---conditional-execution)) based on certain conditions by a `when` block. If all conditions in the `when` block evaluate to true the workflow is executed, otherwise it is skipped, but treated as successful and other workflows depending on it will still continue. For more information about the specific filters, take a look at the [step-specific `when` filters](#when---conditional-execution). Example conditional execution by branch: ```diff +when: + branch: main + steps: - name: prettier image: woodpeckerci/plugin-prettier ``` The workflow now triggers on `main`, but also if the target branch of a pull request is `main`. ## `depends_on` Woodpecker supports to define multiple workflows for a repository. Those workflows will run independent from each other. To depend them on each other you can use the [`depends_on`](./25-workflows.md#flow-control) keyword. ## Advanced network options for steps :::warning Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. ::: ### `dns` If the backend engine understands to change the DNS server and lookup domain, this options will be used to alter the default DNS config to a custom one for a specific step. ```yaml steps: - name: build image: plugin/abc dns: 1.2.3.4 dns_search: 'internal.company' ``` ## Privileged mode Woodpecker gives the ability to configure privileged mode in the YAML. You can use this parameter to launch containers with escalated capabilities. :::info Privileged mode is only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker environment: - DOCKER_HOST=tcp://docker:2375 commands: - docker --tls=false ps services: - name: docker image: docker:dind commands: dockerd-entrypoint.sh --storage-driver=vfs --tls=false + privileged: true ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/25-workflows.md ================================================ # Workflows A pipeline has at least one workflow. A workflow is a set of steps that are executed in sequence using the same workspace which is a shared folder containing the repository and all the generated data from previous steps. In case there is a single configuration in `.woodpecker.yaml` Woodpecker will create a pipeline with a single workflow. By placing the configurations in a folder which is by default named `.woodpecker/` Woodpecker will create a pipeline with multiple workflows each named by the file they are defined in. Only `.yml` and `.yaml` files will be used and files in any subfolders like `.woodpecker/sub-folder/test.yaml` will be ignored. You can also set some custom path like `.my-ci/pipelines/` instead of `.woodpecker/` in the [project settings](./75-project-settings.md). ## Benefits of using workflows - faster lint/test feedback, the workflow doesn't have to run fully to have a lint status pushed to the remote - better organization of a pipeline along various concerns using one workflow for: testing, linting, building and deploying - utilizing more agents to speed up the execution of the whole pipeline ## Example workflow definition :::warning Please note that files are only shared between steps of the same workflow (see [File changes are incremental](./20-workflow-syntax.md#file-changes-are-incremental)). That means you cannot access artifacts e.g. from the `build` workflow in the `deploy` workflow. If you still need to pass artifacts between the workflows you need use some storage [plugin](./51-plugins/51-overview.md) (e.g. one which stores files in an Amazon S3 bucket). ::: ```bash .woodpecker/ ├── build.yaml ├── deploy.yaml ├── lint.yaml └── test.yaml ``` ```yaml title=".woodpecker/build.yaml" steps: - name: build image: debian:stable-slim commands: - echo building - sleep 5 ``` ```yaml title=".woodpecker/deploy.yaml" steps: - name: deploy image: debian:stable-slim commands: - echo deploying depends_on: - lint - build - test ``` ```yaml title=".woodpecker/test.yaml" steps: - name: test image: debian:stable-slim commands: - echo testing - sleep 5 depends_on: - build ``` ```yaml title=".woodpecker/lint.yaml" steps: - name: lint image: debian:stable-slim commands: - echo linting - sleep 5 ``` ## Status lines Each workflow will report its own status back to your forge. ## Flow control The workflows run in parallel on separate agents and share nothing. Dependencies between workflows can be set with the `depends_on` element. A workflow doesn't execute until all of its dependencies finished successfully. The name for a `depends_on` entry is the filename without the path, leading dots and without the file extension `.yml` or `.yaml`. If the project config for example uses `.woodpecker/` as path for CI files with a file named `.woodpecker/.lint.yaml` the corresponding `depends_on` entry would be `lint`. ```diff steps: - name: deploy image: debian:stable-slim commands: - echo deploying +depends_on: + - lint + - build + - test ``` Workflows that need to run even on failures should set the `status` filter. ```diff steps: - name: notify image: debian:stable-slim commands: - echo notifying depends_on: - deploy +when: + - status: [ success, failure ] ``` This works just like the [`status` filter for steps](./20-workflow-syntax.md#status). :::info Some workflows don't need the source code, like creating a notification on failure. Read more about `skip_clone` at [pipeline syntax](./20-workflow-syntax.md#skip_clone) ::: ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/30-matrix-workflows.md ================================================ # Matrix workflows Woodpecker has integrated support for matrix workflows. Woodpecker executes a separate workflow for each combination in the matrix, allowing you to build and test against multiple configurations. :::warning Woodpecker currently supports a maximum of **27 matrix axes** per workflow. If your matrix exceeds this number, any additional axes will be silently ignored. ::: Example matrix definition: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 REDIS_VERSION: - 2.6 - 2.8 - 3.0 ``` Example matrix definition containing only specific combinations: ```yaml matrix: include: - GO_VERSION: 1.4 REDIS_VERSION: 2.8 - GO_VERSION: 1.5 REDIS_VERSION: 2.8 - GO_VERSION: 1.6 REDIS_VERSION: 3.0 ``` ## Interpolation Matrix variables are interpolated in the YAML using the `${VARIABLE}` syntax, before the YAML is parsed. This is an example YAML file before interpolating matrix parameters: ```yaml matrix: GO_VERSION: - 1.4 - 1.3 DATABASE: - mysql:8 - mysql:5 - mariadb:10.1 steps: - name: build image: golang:${GO_VERSION} commands: - go get - go build - go test services: - name: database image: ${DATABASE} ``` Example YAML file after injecting the matrix parameters: ```diff steps: - name: build - image: golang:${GO_VERSION} + image: golang:1.4 commands: - go get - go build - go test + environment: + - GO_VERSION=1.4 + - DATABASE=mysql:8 services: - name: database - image: ${DATABASE} + image: mysql:8 ``` ## Examples ### Example matrix pipeline based on Docker image tag ```yaml matrix: TAG: - 1.7 - 1.8 - latest steps: - name: build image: golang:${TAG} commands: - go build - go test ``` ### Example matrix pipeline based on container image ```yaml matrix: IMAGE: - golang:1.7 - golang:1.8 - golang:latest steps: - name: build image: ${IMAGE} commands: - go build - go test ``` ### Example matrix pipeline using multiple platforms ```yaml matrix: platform: - linux/amd64 - linux/arm64 labels: platform: ${platform} steps: - name: test image: alpine commands: - echo "I am running on ${platform}" - name: test-arm-only image: alpine commands: - echo "I am running on ${platform}" - echo "Arm is cool!" when: platform: linux/arm* ``` :::note If you want to control the architecture of a pipeline on a Kubernetes runner, see [the nodeSelector documentation of the Kubernetes backend](../30-administration/10-configuration/11-backends/20-kubernetes.md#node-selector). ::: ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/40-secrets.md ================================================ # Secrets Woodpecker provides the ability to store named variables in a central secret store. These secrets can be securely passed on to individual pipeline steps using the keyword `from_secret`. There are three different levels of secrets available. If a secret is defined in multiple levels, the following order of priority applies (last wins): 1. **Repository secrets**: Available for all pipelines of a repository. 1. **Organization secrets**: Available for all pipelines of an organization. 1. **Global secrets**: Can only be set by instance administrators. Global secrets are available for all pipelines of the **entire** Woodpecker instance and should therefore be used with caution. In addition to the native integration of secrets, external providers of secrets can also be used by interacting with them directly within pipeline steps. Access to these providers can be configured with Woodpecker secrets, which enables the retrieval of secrets from the respective external sources. :::warning Woodpecker can mask secrets from its own secrets store, but it cannot apply the same protection to external secrets. As a result, these external secrets can be exposed in the pipeline logs. ::: ## Usage You can set a setting or environment value from Woodpecker secrets by using the `from_secret` syntax. The following example passes a secret called `secret_token` which is stored in an environment variable called `TOKEN_ENV`: ```diff steps: - name: 'step name' image: registry/repo/image:tag commands: + - echo "The secret is $TOKEN_ENV" + environment: + TOKEN_ENV: + from_secret: secret_token ``` The same syntax can be used to pass secrets to (plugin) settings. A secret called `secret_token` is assigned to the setting `TOKEN`, which is then available in the plugin as the environment variable `PLUGIN_TOKEN` (see [plugins](./51-plugins/20-creating-plugins.md#settings) for details). `PLUGIN_TOKEN` is then used internally by the plugin itself and taken into account during execution. ```diff steps: - name: 'step name' image: registry/repo/image:tag + settings: + TOKEN: + from_secret: secret_token ``` ### Escape secrets Please note that parameter expressions are preprocessed, i.e. they are evaluated before the pipeline starts. If secrets are to be used in expressions, they must be properly escaped (with `$$`) to ensure correct processing. ```diff steps: - name: docker image: docker commands: - - echo ${TOKEN_ENV} + - echo $${TOKEN_ENV} environment: TOKEN_ENV: from_secret: secret_token ``` ### Events filter By default, secrets are not exposed to pull requests. However, you can change this behavior by creating the secret and enabling the `pull_request` event type. This can be configured either via the UI or via the CLI. :::warning Be careful when exposing secrets for pull requests. If your repository is public and accepts pull requests from everyone, your secrets may be at risk. Malicious actors could take advantage of this to expose your secrets or transfer them to an external location. ::: ### Plugins filter To prevent your secrets from being misused by malicious users, you can restrict a secret to a list of plugins. If enabled, they are not available to any other plugins. Plugins have the advantage that they cannot execute arbitrary commands and therefore cannot reveal secrets. :::tip If you specify a tag, the filter will take it into account. However, if the same image appears several times in the list, the least privileged entry will take precedence. For example, an image without a tag will allow all tags, even if it contains another entry with a tag attached. ::: ![plugins filter](./secrets-plugins-filter.png) ## CLI In addition to the UI, secrets can also be managed using the CLI. Create the secret with the default settings. The secret is available for all images in your pipeline and for all `push`, `tag` and `deployment` events (not for `pull_request` events). ```bash woodpecker-cli repo secret add \ --repository octocat/hello-world \ --name aws_access_key_id \ --value ``` Create the secret and limit it to a single image: ```diff woodpecker-cli secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ --name aws_access_key_id \ --value ``` Create the secrets and limit it to a set of images: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ + --image woodpeckerci/plugin-s3 \ + --image woodpeckerci/plugin-docker-buildx \ --name aws_access_key_id \ --value ``` Create the secret and enable it for multiple hook events: ```diff woodpecker-cli repo secret add \ --repository octocat/hello-world \ --image woodpeckerci/plugin-s3 \ + --event pull_request \ + --event push \ + --event tag \ --name aws_access_key_id \ --value ``` Secrets can be loaded from a file using the syntax `@`. This method is recommended for loading secrets from a file, as it ensures that line breaks are preserved (this is important for SSH keys, for example): ```diff woodpecker-cli repo secret add \ -repository octocat/hello-world \ -name ssh_key \ + -value @/root/ssh/id_rsa ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/41-registries.md ================================================ # Registries Woodpecker provides the ability to add container registries in the settings of your repository. Adding a registry allows you to authenticate and pull private images from a container registry when using these images as a step inside your pipeline. Using registry credentials can also help you avoid rate limiting when pulling images from public registries. ## Images from private registries You must provide registry credentials in the UI in order to pull private container images defined in your YAML configuration file. These credentials are never exposed to your steps, which means they cannot be used to push, and are safe to use with pull requests, for example. Pushing to a registry still requires setting credentials for the appropriate plugin. Example configuration using a private image: ```diff steps: - name: build + image: gcr.io/custom/golang commands: - go build - go test ``` Woodpecker matches the registry hostname to each image in your YAML. If the hostnames match, the registry credentials are used to authenticate to your registry and pull the image. Note that registry credentials are used by the Woodpecker agent and are never exposed to your build containers. Example registry hostnames: - Image `gcr.io/foo/bar` has hostname `gcr.io` - Image `foo/bar` has hostname `docker.io` - Image `qux.com:8000/foo/bar` has hostname `qux.com:8000` Example registry hostname matching logic: - Hostname `gcr.io` matches image `gcr.io/foo/bar` - Hostname `docker.io` matches `golang` - Hostname `docker.io` matches `library/golang` - Hostname `docker.io` matches `bradrydzewski/golang` - Hostname `docker.io` matches `bradrydzewski/golang:latest` ## Global registry support To make a private registry globally available, check the [server configuration docs](../30-administration/10-configuration/10-server.md#docker_config). ## GCR registry support For specific details on configuring access to Google Container Registry, please view the docs [here](https://cloud.google.com/container-registry/docs/advanced-authentication#using_a_json_key_file). ## Local Images :::warning For this, privileged rights are needed only available to admins. In addition, this only works when using a single agent. ::: It's possible to build a local image by mounting the docker socket as a volume. With a `Dockerfile` at the root of the project: ```yaml steps: - name: build-image image: docker commands: - docker build --rm -t local/project-image . volumes: - /var/run/docker.sock:/var/run/docker.sock - name: build-project image: local/project-image commands: - ./build.sh ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/45-cron.md ================================================ # Cron To configure cron jobs you need at least push access to the repository. ## Add a new cron job 1. To create a new cron job adjust your pipeline config(s) and add the event filter to all steps you would like to run by the cron job: ```diff steps: - name: sync_locales image: weblate_sync settings: url: example.com token: from_secret: weblate_token + when: + event: cron + cron: "name of the cron job" # if you only want to execute this step by a specific cron job ``` 2. Create a new cron job in the repository settings: ![cron settings](./cron-settings.png) The supported schedule syntax can be found at . If you need general understanding of the cron syntax is a good place to start and experiment. Examples: `@every 5m`, `@daily`, `30 * * * *` ... ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/50-environment.md ================================================ # Environment variables Woodpecker provides the ability to pass environment variables to individual pipeline steps. Note that these can't overwrite any existing, built-in variables. Example pipeline step with custom environment variables: ```diff steps: - name: build image: golang + environment: + CGO: 0 + GOOS: linux + GOARCH: amd64 commands: - go build - go test ``` Please note that the environment section is not able to expand environment variables. If you need to expand variables they should be exported in the commands section. ```diff steps: - name: build image: golang - environment: - - PATH=$PATH:/go commands: + - export PATH=$PATH:/go - go build - go test ``` :::warning `${variable}` expressions are subject to pre-processing. If you do not want the pre-processor to evaluate your expression it must be escaped: ::: ```diff steps: - name: build image: golang commands: - - export PATH=${PATH}:/go + - export PATH=$${PATH}:/go - go build - go test ``` ## Built-in environment variables This is the reference list of all environment variables available to your pipeline containers. These are injected into your pipeline step and plugins containers, at runtime. | NAME | Description | Example | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | `CI` | CI environment name | `woodpecker` | | | **Repository** | | | `CI_REPO` | repository full name `/` | `john-doe/my-repo` | | `CI_REPO_OWNER` | repository owner | `john-doe` | | `CI_REPO_NAME` | repository name | `my-repo` | | `CI_REPO_REMOTE_ID` | repository remote ID, is the UID it has in the forge | `82` | | `CI_REPO_URL` | repository web URL | `https://git.example.com/john-doe/my-repo` | | `CI_REPO_CLONE_URL` | repository clone URL | `https://git.example.com/john-doe/my-repo.git` | | `CI_REPO_CLONE_SSH_URL` | repository SSH clone URL | `git@git.example.com:john-doe/my-repo.git` | | `CI_REPO_DEFAULT_BRANCH` | repository default branch | `main` | | `CI_REPO_PRIVATE` | repository is private | `true` | | `CI_REPO_TRUSTED_NETWORK` | repository has trusted network access | `false` | | `CI_REPO_TRUSTED_VOLUMES` | repository has trusted volumes access | `false` | | `CI_REPO_TRUSTED_SECURITY` | repository has trusted security access | `false` | | | **Current Commit** | | | `CI_COMMIT_SHA` | commit SHA | `eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_COMMIT_REF` | commit ref | `refs/heads/main` | | `CI_COMMIT_REFSPEC` | commit ref spec | `issue-branch:main` | | `CI_COMMIT_BRANCH` | commit branch (equals target branch for pull requests) | `main` | | `CI_COMMIT_SOURCE_BRANCH` | commit source branch (set only for pull request events) | `issue-branch` | | `CI_COMMIT_TARGET_BRANCH` | commit target branch (set only for pull request events) | `main` | | `CI_COMMIT_TAG` | commit tag name (empty if event is not `tag`) | `v1.10.3` | | `CI_COMMIT_PULL_REQUEST` | commit pull request number (set only for pull request events) | `1` | | `CI_COMMIT_PULL_REQUEST_LABELS` | labels assigned to pull request (set only for pull request events) | `server` | | `CI_COMMIT_PULL_REQUEST_MILESTONE` | milestone assigned to pull request (set only for `pull_request` and `pull_request_closed` events) | `summer-sprint` | | `CI_COMMIT_MESSAGE` | commit message | `Initial commit` | | `CI_COMMIT_AUTHOR` | commit author username | `john-doe` | | `CI_COMMIT_AUTHOR_EMAIL` | commit author email address | `john-doe@example.com` | | `CI_COMMIT_PRERELEASE` | release is a pre-release (empty if event is not `release`) | `false` | | | **Current pipeline** | | | `CI_PIPELINE_NUMBER` | pipeline number | `8` | | `CI_PIPELINE_PARENT` | number of parent pipeline | `0` | | `CI_PIPELINE_EVENT` | pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PIPELINE_EVENT_REASON` | exact reason why `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PIPELINE_URL` | link to the web UI for the pipeline | `https://ci.example.com/repos/7/pipeline/8` | | `CI_PIPELINE_FORGE_URL` | link to the forge's web UI for the commit(s) or tag that triggered the pipeline | `https://git.example.com/john-doe/my-repo/commit/eba09b46064473a1d345da7abf28b477468e8dbd` | | `CI_PIPELINE_DEPLOY_TARGET` | pipeline deploy target for `deployment` events | `production` | | `CI_PIPELINE_DEPLOY_TASK` | pipeline deploy task for `deployment` events | `migration` | | `CI_PIPELINE_CREATED` | pipeline created UNIX timestamp | `1722617519` | | `CI_PIPELINE_STARTED` | pipeline started UNIX timestamp | `1722617519` | | `CI_PIPELINE_FILES` | changed files (empty if event is not `push` or `pull_request`), it is undefined if more than 500 files are touched | `[]`, `[".woodpecker.yml","README.md"]` | | `CI_PIPELINE_AUTHOR` | pipeline author username | `octocat` | | `CI_PIPELINE_AVATAR` | pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | | **Current workflow** | | | `CI_WORKFLOW_NAME` | workflow name | `release` | | | **Current step** | | | `CI_STEP_NAME` | step name | `build package` | | `CI_STEP_NUMBER` | step number | `0` | | `CI_STEP_STARTED` | step started UNIX timestamp | `1722617519` | | `CI_STEP_URL` | URL to step in UI | `https://ci.example.com/repos/7/pipeline/8` | | | **Previous commit** | | | `CI_PREV_COMMIT_SHA` | previous commit SHA | `15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_REF` | previous commit ref | `refs/heads/main` | | `CI_PREV_COMMIT_REFSPEC` | previous commit ref spec | `issue-branch:main` | | `CI_PREV_COMMIT_BRANCH` | previous commit branch | `main` | | `CI_PREV_COMMIT_SOURCE_BRANCH` | previous commit source branch (set only for pull request events) | `issue-branch` | | `CI_PREV_COMMIT_TARGET_BRANCH` | previous commit target branch (set only for pull request events) | `main` | | `CI_PREV_COMMIT_URL` | previous commit link in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_COMMIT_MESSAGE` | previous commit message | `test` | | `CI_PREV_COMMIT_AUTHOR` | previous commit author username | `john-doe` | | `CI_PREV_COMMIT_AUTHOR_EMAIL` | previous commit author email address | `john-doe@example.com` | | | **Previous pipeline** | | | `CI_PREV_PIPELINE_NUMBER` | previous pipeline number | `7` | | `CI_PREV_PIPELINE_PARENT` | previous pipeline number of parent pipeline | `0` | | `CI_PREV_PIPELINE_EVENT` | previous pipeline event (see [`event`](../20-usage/20-workflow-syntax.md#event)) | `push`, `pull_request`, `pull_request_closed`, `pull_request_metadata`, `tag`, `release`, `manual`, `cron` | | `CI_PREV_PIPELINE_EVENT_REASON` | previous exact reason `pull_request_metadata` event was send. it is forge instance specific and can change | `label_updated`, `milestoned`, `demilestoned`, `assigned`, `edited`, ... | | `CI_PREV_PIPELINE_URL` | previous pipeline link in CI | `https://ci.example.com/repos/7/pipeline/7` | | `CI_PREV_PIPELINE_FORGE_URL` | previous pipeline link to event in forge | `https://git.example.com/john-doe/my-repo/commit/15784117e4e103f36cba75a9e29da48046eb82c4` | | `CI_PREV_PIPELINE_DEPLOY_TARGET` | previous pipeline deploy target for `deployment` events | `production` | | `CI_PREV_PIPELINE_DEPLOY_TASK` | previous pipeline deploy task for `deployment` events | `migration` | | `CI_PREV_PIPELINE_STATUS` | previous pipeline status | `success`, `failure` | | `CI_PREV_PIPELINE_CREATED` | previous pipeline created UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_STARTED` | previous pipeline started UNIX timestamp | `1722610173` | | `CI_PREV_PIPELINE_FINISHED` | previous pipeline finished UNIX timestamp | `1722610383` | | `CI_PREV_PIPELINE_AUTHOR` | previous pipeline author username | `octocat` | | `CI_PREV_PIPELINE_AVATAR` | previous pipeline author avatar | `https://git.example.com/avatars/5dcbcadbce6f87f8abef` | | |   | | | `CI_WORKSPACE` | Path of the workspace where source code gets cloned to | `/woodpecker/src/git.example.com/john-doe/my-repo` | | | **System** | | | `CI_SYSTEM_NAME` | name of the CI system | `woodpecker` | | `CI_SYSTEM_URL` | link to CI system | `https://ci.example.com` | | `CI_SYSTEM_HOST` | hostname of CI server | `ci.example.com` | | `CI_SYSTEM_VERSION` | version of the server | `2.7.0` | | | **Forge** | | | `CI_FORGE_TYPE` | name of forge | `bitbucket` , `bitbucket_dc` , `forgejo` , `gitea` , `github` , `gitlab` | | `CI_FORGE_URL` | root URL of configured forge | `https://git.example.com` | | | **Internal** - Please don't use! | | | `CI_SCRIPT` | Internal script path. Used to call pipeline step commands. | | | `CI_NETRC_USERNAME` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_PASSWORD` | Credentials for private repos to be able to clone data. (Only available for specific images) | | | `CI_NETRC_MACHINE` | Credentials for private repos to be able to clone data. (Only available for specific images) | | ## Global environment variables If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` These can be used, for example, to manage the image tag used by multiple projects. ```ini WOODPECKER_ENVIRONMENT=GOLANG_VERSION:1.18 ``` ```diff steps: - name: build - image: golang:1.18 + image: golang:${GOLANG_VERSION} commands: - [...] ``` ## String Substitution Woodpecker provides the ability to substitute environment variables at runtime. This gives us the ability to use dynamic settings, commands and filters in our pipeline configuration. Example commit substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA} ``` Example tag substitution: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG} ``` ## String Operations Woodpecker also emulates bash string operations. This gives us the ability to manipulate the strings prior to substitution. Example use cases might include substring and stripping prefix or suffix values. | OPERATION | DESCRIPTION | | ------------------ | ------------------------------------------------ | | `${param}` | parameter substitution | | `${param,}` | parameter substitution with lowercase first char | | `${param,,}` | parameter substitution with lowercase | | `${param^}` | parameter substitution with uppercase first char | | `${param^^}` | parameter substitution with uppercase | | `${param:pos}` | parameter substitution with substring | | `${param:pos:len}` | parameter substitution with substring and length | | `${param=default}` | parameter substitution with default | | `${param##prefix}` | parameter substitution with prefix removal | | `${param%%suffix}` | parameter substitution with suffix removal | | `${param/old/new}` | parameter substitution with find and replace | Example variable substitution with substring: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_SHA:0:8} ``` Example variable substitution strips `v` prefix from `v.1.0.0`: ```diff steps: - name: s3 image: woodpeckerci/plugin-s3 settings: + target: /target/${CI_COMMIT_TAG##v} ``` ## `pull_request_metadata` specific event reason values For the `pull_request_metadata` event, the exact reason a metadata change was detected is passe through in `CI_PIPELINE_EVENT_REASON`. **GitLab** merges metadata updates into one webhook. Event reasons are separated by `,` as a list. :::note Event reason values are forge-specific and may change between versions. ::: | Event | GitHub | Gitea | Forgejo | GitLab | Bitbucket | Bitbucket Datacenter | Description | | -------------------- | ------------------ | ------------------ | ------------------ | ------------------ | --------- | -------------------- | ------------------------------------------------------------------------------ | | `assigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was assigned to a user | | `converted_to_draft` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Pull request was converted to a draft | | `demilestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was removed from a milestone | | `description_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Description edited | | `edited` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | :x: | The title or body of a pull request was edited, or the base branch was changed | | `label_added` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Pull had no labels and now got label(s) added | | `label_cleared` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | All labels removed | | `label_updated` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | New label(s) added / label(s) changed | | `locked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was locked | | `milestoned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | Pull request was added to a milestone | | `ready_for_review` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Draft pull request was marked as ready for review | | `review_requested` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | New review was requested | | `title_edited` | :x: | :x: | :x: | :white_check_mark: | :x: | :x: | Title edited | | `unassigned` | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | User was unassigned from a pull request | | `unlabeled` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Label was removed from a pull request | | `unlocked` | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | Conversation on a pull request was unlocked | **Bitbucket** and **Bitbucket Datacenter** [are not supported at the moment](https://github.com/woodpecker-ci/woodpecker/pull/5214). ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/51-plugins/20-creating-plugins.md ================================================ # Creating plugins Creating a new plugin is simple: Build a Docker container which uses your plugin logic as the ENTRYPOINT. ## Settings To allow users to configure the behavior of your plugin, you should use `settings:`. These are passed to your plugin as uppercase env vars with a `PLUGIN_` prefix. Using a setting like `url` results in an env var named `PLUGIN_URL`. Characters like `-` are converted to an underscore (`_`). `some_String` gets `PLUGIN_SOME_STRING`. CamelCase is not respected, `anInt` get `PLUGIN_ANINT`. ### Basic settings Using any basic YAML type (scalar) will be converted into a string: | Setting | Environment value | | -------------------- | ---------------------------- | | `some-bool: false` | `PLUGIN_SOME_BOOL="false"` | | `some_String: hello` | `PLUGIN_SOME_STRING="hello"` | | `anInt: 3` | `PLUGIN_ANINT="3"` | ### Complex settings It's also possible to use complex settings like this: ```yaml steps: - name: plugin image: foo/plugin settings: complex: abc: 2 list: - 2 - 3 ``` Values like this are converted to JSON and then passed to your plugin. In the example above, the environment variable `PLUGIN_COMPLEX` would contain `{"abc": "2", "list": [ "2", "3" ]}`. ### Secrets Secrets should be passed as settings too. Therefore, users should use [`from_secret`](../40-secrets.md#usage). ## Plugin library For Go, we provide a plugin library you can use to get easy access to internal env vars and your settings. See . ## Metadata In your documentation, you can use a Markdown header to define metadata for your plugin. This data is used by [our plugin index](/plugins). Supported metadata: - `name`: The plugin's full name - `icon`: URL to your plugin's icon - `description`: A short description of what it's doing - `author`: Your name - `tags`: List of keywords (e.g. `[git, clone]` for the clone plugin) - `containerImage`: name of the container image - `containerImageUrl`: link to the container image - `url`: homepage or repository of your plugin If you want your plugin to be listed in the index, you should add as many fields as possible, but only `name` is required. ## Example plugin This provides a brief tutorial for creating a Woodpecker webhook plugin, using simple shell scripting, to make HTTP requests during the build pipeline. ### What end users will see The below example demonstrates how we might configure a webhook plugin in the YAML file: ```yaml steps: - name: webhook image: foo/webhook settings: url: https://example.com method: post body: | hello world ``` ### Write the logic Create a simple shell script that invokes curl using the YAML configuration parameters, which are passed to the script as environment variables in uppercase and prefixed with `PLUGIN_`. ```bash #!/bin/sh curl \ -X ${PLUGIN_METHOD} \ -d ${PLUGIN_BODY} \ ${PLUGIN_URL} ``` ### Package it Create a Dockerfile that adds your shell script to the image, and configures the image to execute your shell script as the main entrypoint. ```dockerfile # please pin the version, e.g. alpine:3.19 FROM alpine ADD script.sh /bin/ RUN chmod +x /bin/script.sh RUN apk -Uuv add curl ca-certificates ENTRYPOINT /bin/script.sh ``` Build and publish your plugin to the Docker registry. Once published, your plugin can be shared with the broader Woodpecker community. ```shell docker build -t foo/webhook . docker push foo/webhook ``` Execute your plugin locally from the command line to verify it is working: ```shell docker run --rm \ -e PLUGIN_METHOD=post \ -e PLUGIN_URL=https://example.com \ -e PLUGIN_BODY="hello world" \ foo/webhook ``` ## Best practices - Build your plugin for different architectures to allow many users to use them. At least, you should support `amd64` and `arm64`. - Provide binaries for users using the `local` backend. These should also be built for different OS/architectures. - Use [built-in env vars](../50-environment.md#built-in-environment-variables) where possible. - Do not use any configuration except settings (and internal env vars). This means: Don't require using [`environment`](../50-environment.md) and don't require specific secret names. - Add a `docs.md` file, listing all your settings and plugin metadata ([example](https://github.com/woodpecker-ci/plugin-git/blob/main/docs.md)). - Add your plugin to the [plugin index](/plugins) using your `docs.md` ([the example above in the index](https://woodpecker-ci.org/plugins/git-clone)). ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/51-plugins/51-overview.md ================================================ # Plugins Plugins are pipeline steps that perform pre-defined tasks and are configured as steps in your pipeline. Plugins can be used to deploy code, publish artifacts, send notification, and more. They are automatically pulled from the default container registry the agent's have configured. ```dockerfile title="Dockerfile" FROM cloud/kubectl COPY deploy /usr/local/deploy ENTRYPOINT ["/usr/local/deploy"] ``` ```bash title="deploy" kubectl apply -f $PLUGIN_TEMPLATE ``` ```yaml title=".woodpecker.yaml" steps: - name: deploy-to-k8s image: cloud/my-k8s-plugin settings: template: config/k8s/service.yaml ``` Example pipeline using the Prettier and S3 plugins: ```yaml steps: - name: build image: golang commands: - go build - go test - name: prettier image: woodpeckerci/plugin-prettier - name: publish image: woodpeckerci/plugin-s3 settings: bucket: my-bucket-name source: some-file-name target: /target/some-file ``` ## Plugin Isolation Plugins are just pipeline steps. They share the build workspace, mounted as a volume, and therefore have access to your source tree. While normal steps are all about arbitrary code execution, plugins should only allow the functions intended by the plugin author. That's why there are a few limitations. The workspace base is always mounted at `/woodpecker`, but the working directory is dynamically adjusted accordingly, as user of a plugin you should not have to care about this. Also, you cannot use the plugin together with `commands` or `entrypoint` which will fail. Using `environment` is possible, but in this case, the plugin is internally not treated as plugin anymore. The container then cannot access secrets with plugin filter anymore and the containers won't be privileged without explicit definition. ## Finding Plugins For official plugins, you can use the Woodpecker plugin index: - [Official Woodpecker Plugins](https://woodpecker-ci.org/plugins) :::tip There are also other plugin lists with additional plugins. Keep in mind that [Drone](https://www.drone.io/) plugins are generally supported, but could need some adjustments and tweaking. - [Drone Plugins](http://plugins.drone.io) - [Geeklab Woodpecker Plugins](https://woodpecker-plugins.geekdocs.de/) - [Woodpecker Community Plugins](https://codeberg.org/woodpecker-community) ::: ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/51-plugins/_category_.yaml ================================================ label: 'Plugins' # position: 2 collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/60-services.md ================================================ # Services Woodpecker provides a services section in the YAML file used for defining service containers. The below configuration composes database and cache containers. Services are accessed using custom hostnames. In the example below, the MySQL service is assigned the hostname `database` and is available at `database:3306`. ```yaml steps: - name: build image: golang commands: - go build - go test services: - name: database image: mysql - name: cache image: redis ``` You can define a port and a protocol explicitly: ```yaml services: - name: database image: mysql ports: - 3306 - name: wireguard image: wg ports: - 51820/udp ``` ## Stopping Services that are no longer needed receive a **SIGTERM** signal. If they do not respond, they are forcibly terminated with **SIGKILL**. If there are services that do not shut down properly and this doesn't matter, you can simply ignore the error: ```diff services: - name: database image: mysql + failure: ignore # we don't care how mysql exits ports: - 3306 ``` ## Configuration Service containers generally expose environment variables to customize service startup such as default usernames, passwords and ports. Please see the official image documentation to learn more. ```diff services: - name: database image: mysql + environment: + MYSQL_DATABASE: test + MYSQL_ALLOW_EMPTY_PASSWORD: yes - name: cache image: redis ``` ## Detachment Service and long running containers can also be included in the pipeline section of the configuration using the detach parameter without blocking other steps. This should be used when explicit control over startup order is required. ```diff steps: - name: build image: golang commands: - go build - go test - name: database image: redis + detach: true - name: test image: golang commands: - go test ``` Containers from detached steps will terminate when the pipeline ends. ## Initialization Service containers require time to initialize and begin to accept connections. If you are unable to connect to a service you may need to wait a few seconds or implement a backoff. ```diff steps: - name: test image: golang commands: + - sleep 15 - go get - go test services: - name: database image: mysql ``` ## Complete Pipeline Example ```yaml services: - name: database image: mysql environment: MYSQL_DATABASE: test MYSQL_ROOT_PASSWORD: example steps: - name: get-version image: ubuntu commands: - ( apt update && apt dist-upgrade -y && apt install -y mysql-client 2>&1 )> /dev/null - sleep 30s # need to wait for mysql-server init - echo 'SHOW VARIABLES LIKE "version"' | mysql -u root -h database test -p example ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/70-volumes.md ================================================ # Volumes Woodpecker gives the ability to define Docker volumes in the YAML. You can use this parameter to mount files or folders on the host machine into your containers. :::note Volumes are only available to trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable trusted mode. ::: ```diff steps: - name: build image: docker commands: - docker build --rm -t octocat/hello-world . - docker run --rm octocat/hello-world --test - docker push octocat/hello-world - docker rmi octocat/hello-world volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` If you use the Docker backend, you can also use named volumes like `some_volume_name:/var/run/volume`. Please note that Woodpecker mounts volumes on the host machine. This means you must use absolute paths when you configure volumes. Attempting to use relative paths will result in an error. ```diff -volumes: [ ./certs:/etc/ssl/certs ] +volumes: [ /etc/ssl/certs:/etc/ssl/certs ] ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/72-extensions/40-configuration-extension.md ================================================ # Configuration extension The configuration extension can be used to modify or generate Woodpeckers pipeline configurations. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Preprocess the original configuration file with something like Go templating - Convert custom attributes to Woodpecker attributes - Add defaults to the configuration like default steps - Convert configuration files from a totally different format like Gitlab CI config, Starlark, Jsonnet, ... - Centralize configuration for multiple repositories in one place ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your configuration extension. The global configuration will be called before the repository specific configuration extension if both are configured and the repository has not enabled the exclusive setting. ```ini title="Server" WOODPECKER_CONFIG_EXTENSION_ENDPOINT=https://example.com/ciconfig ``` ## How it works When a pipeline is triggered Woodpecker will fetch the pipeline configuration from the repository, then make a HTTP POST request to the configured extension with a JSON payload containing some data like the repository, pipeline information and the current config files retrieved from the repository. The extension can then send back modified or even new pipeline configurations following Woodpeckers official yaml format that should be used. You can enable the exclusive setting (both globally and on a per-repo level). Then Woodpecker will only call your extension, but nothing else. This allows you to completely skip the forge. Requests sent to the extension will not have the configuration files added. ### Request The extension receives an HTTP POST request with the following JSON payload: :::info The `netrc` field is only included in the request when the global `WOODPECKER_CONFIG_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked. ::: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc?: Netrc; // only included when netrc sending is enabled (see above) configuration?: { // list of configurations. Not send if there was none. name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "configuration": [ { "name": ".woodpecker.yaml", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from Repo (.woodpecker.yaml)\"\n" } ], "netrc": { "machine": "myforge.com", "login": "myUser", "password": "forge-access-token" } } ``` ### Response The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. ```ts class Response { configs: { name: string; // filename of the configuration file data: string; // content of the configuration file }[]; } ``` Example response: ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/72-extensions/50-registry-extension.md ================================================ # Registry extension Woodpecker uses the registry extension to get registry credentials. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Centralize registry credential management - Use an external storage for credentials - Dynamically manage which credentials Woodpecker should use ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the [security section](./index.md#security). ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your registry extension. If both the global and the repo-level extension return credentials for a registry, it will use the credentials from the repo extension. ```ini title="Server" WOODPECKER_REGISTRY_EXTENSION_ENDPOINT=https://example.com/ciconfig ``` ## How it works When a pipeline is triggered, Woodpecker will fetch the credentials from your service. As fallback, it uses the credentials configured directly in Woodpecker. ### Request The extension receives an HTTP POST request with the following JSON payload: :::info The `netrc` field is only included in the request when the global `WOODPECKER_REGISTRY_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked. ::: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc?: Netrc; // only included when netrc sending is enabled (see above) } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json // Please check the latest structure in the models mentioned above. // This example is likely outdated. { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "myforge.com", "login": "myUser", "password": "forge-access-token" } } ``` ### Response The extension should respond with a JSON payload containing the new configuration files in Woodpecker's official YAML format. If the extension wants to keep the existing configuration files, it can respond with HTTP status `204 No Content`. ```ts class Response { registries: { address: string; // the docker registry address username: string; // registry username password: string; // registry password }[]; } ``` Example response: ```json { "registries": [ { "address": "docker.io", "username": "woodpecker-bot", "password": "your-pass-word-123" } ] } ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/72-extensions/55-secret-extension.md ================================================ # Secret extension Woodpecker uses the secret extension to get secrets from an external service. You can configure an HTTP endpoint in the repository settings in the extensions tab. Using such an extension can be useful if you want to: - Centralize secret management (e.g. HashiCorp Vault, AWS Secrets Manager) - Dynamically generate secrets per pipeline ## Security :::warning As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the security section. ::: ## Global configuration In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if you share your Woodpecker server with others as they will also use your secret extension. If both the global and the repo-level extension return a secret with the same name, it will use the secret from the repo extension. ```ini title="Server" WOODPECKER_SECRET_EXTENSION_ENDPOINT=https://example.com/secrets WOODPECKER_SECRET_EXTENSION_NETRC=false ``` ## How it works When a pipeline is triggered, Woodpecker will fetch secrets from your service. The extension secrets are merged with the secrets configured directly in Woodpecker, with extension secrets taking priority by name. If the extension is unavailable, Woodpecker falls back to the locally configured secrets. ### Request The extension receives an HTTP POST request with the following JSON payload: :::info The `netrc` field is only included in the request when the global `WOODPECKER_SECRET_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked. ::: ```ts class Request { repo: Repo; pipeline: Pipeline; netrc?: Netrc; // only included when netrc sending is enabled (see above) } ``` Checkout the following models for more information: - [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go) - [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go) - [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go) :::tip The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository. ::: Example request: ```json // Please check the latest structure in the models mentioned above. // This example is likely outdated. { "repo": { "id": 100, "uid": "", "user_id": 0, "namespace": "", "name": "woodpecker-test-pipeline", "slug": "", "scm": "git", "git_http_url": "", "git_ssh_url": "", "link": "", "default_branch": "", "private": true, "visibility": "private", "active": true, "config": "", "trusted": false, "protected": false, "ignore_forks": false, "ignore_pulls": false, "cancel_pulls": false, "timeout": 60, "counter": 0, "synced": 0, "created": 0, "updated": 0, "version": 0 }, "pipeline": { "author": "myUser", "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03", "author_email": "my@email.com", "branch": "main", "changed_files": ["some-filename.txt"], "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50", "created_at": 0, "deploy_to": "", "enqueued_at": 0, "error": "", "event": "push", "finished_at": 0, "id": 0, "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50", "message": "test old config\n", "number": 0, "parent": 0, "ref": "refs/heads/main", "refspec": "", "clone_url": "", "reviewed_at": 0, "reviewed_by": "", "sender": "myUser", "signed": false, "started_at": 0, "status": "", "timestamp": 1645962783, "title": "", "updated_at": 0, "verified": false }, "netrc": { "machine": "myforge.com", "login": "myUser", "password": "forge-access-token" } } // Note: the "netrc" field is omitted when netrc sending is not enabled. ``` ### Response The extension should respond with a JSON object containing a `secrets` array. If the extension wants to keep the existing secrets without adding any, it can respond with HTTP status `204 No Content`. ```ts class Response { secrets: { name: string; // the secret name, matched by from_secret in pipeline config value: string; // the secret value images?: string[]; // optional: restrict to specific plugins events?: string[]; // optional: restrict to specific pipeline events }[]; } ``` Example response: ```json { "secrets": [ { "name": "docker_password", "value": "your-secret-password-123" }, { "name": "deploy_token", "value": "super-secret-token", "events": ["push", "tag"] } ] } ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/72-extensions/_category_.yaml ================================================ label: 'Extensions' # position: 3 collapsible: true collapsed: true link: type: 'doc' id: 'index' ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/72-extensions/index.md ================================================ # Extensions Woodpecker allows you to replace internal logic with external extensions by using pre-defined http endpoints. There is currently one type of extension available: - [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly. - [Registry extension](./50-registry-extension.md) to get registry credentials from the extension. - [Secret extension](./55-secret-extension.md) to get secrets from an external service. ## Security :::warning You need to trust the extensions as they are receiving private information like secrets and tokens and might return harmful data like malicious pipeline configurations that could be executed. ::: To prevent your extensions from such attacks, Woodpecker is signing all HTTP requests using [HTTP signatures](https://tools.ietf.org/html/draft-cavage-http-signatures). Woodpecker therefore uses a public-private ed25519 key pair. To verify the requests your extension has to verify the signature of all request using the public key with some library like [httpsign](https://github.com/yaronf/httpsign). You can get the public Woodpecker key by opening `http://my-woodpecker.tld/api/signature/public-key` or by visiting the Woodpecker UI, going to you repo settings and opening the extensions page. ## Example extensions A simplistic service providing endpoints for a config and secrets extension can be found here: [https://github.com/woodpecker-ci/example-extensions](https://github.com/woodpecker-ci/example-extensions) ## Configuration To prevent extensions from calling local services by default only external hosts / ip-addresses are allowed. You can change this behavior by setting the `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` environment variable. You can use a comma separated list of: - Built-in networks: - `loopback`: 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included. - `private`: RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet. - `external`: A valid non-private unicast IP, you can access all hosts on public internet. - `*`: All hosts are allowed. - CIDR list: `1.2.3.0/8` for IPv4 and `2001:db8::/32` for IPv6 - (Wildcard) hosts: `example.com`, `*.example.com`, `192.168.100.*` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/72-linter.md ================================================ # Linter Woodpecker automatically lints your workflow files for errors, deprecations and bad habits. Errors and warnings are shown in the UI for any pipelines. ![errors and warnings in UI](./linter-warnings-errors.png) ## Running the linter from CLI You can run the linter also manually from the CLI: ```shell woodpecker-cli lint ``` ## Bad habit warnings Woodpecker warns you if your configuration contains some bad habits. ### Event filter for all steps All your items in `when` blocks should have an `event` filter, so no step runs on all events. This is recommended because if new events are added, your steps probably shouldn't run on those as well. Examples of an **incorrect** config for this rule: ```yaml when: - branch: main - event: tag ``` This will trigger the warning because the first item (`branch: main`) does not filter with an event. ```yaml steps: - name: test when: branch: main - name: deploy when: event: tag ``` Examples of a **correct** config for this rule: ```yaml when: - branch: main event: push - event: tag ``` ```yaml steps: - name: test when: event: [tag, push] - name: deploy when: - event: tag ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/75-project-settings.md ================================================ # Project settings As the owner of a project in Woodpecker you can change project related settings via the web interface. ![project settings](./project-settings.png) ## Pipeline path The path to the pipeline config file or folder. By default it is left empty which will use the following configuration resolution `.woodpecker/*.{yaml,yml}` -> `.woodpecker.yaml` -> `.woodpecker.yml`. If you set a custom path Woodpecker tries to load your configuration or fails if no configuration could be found at the specified location. To use a [multiple workflows](./25-workflows.md) with a custom path you have to change it to a folder path ending with a `/` like `.woodpecker/`. ## Repository hooks Your Version-Control-System will notify Woodpecker about events via webhooks. If you want your pipeline to only run on specific webhooks, you can check them with this setting. ## Allow pull requests Enables handling webhook's pull request event. If disabled, then pipeline won't run for pull requests. ## Allow deployments Enables a pipeline to be started with the `deploy` event from a successful pipeline. :::danger Only activate this option if you trust all users who have push access to your repository. Otherwise, these users will be able to steal secrets that are only available for `deploy` events. ::: ## Require approval for To prevent malicious pipelines from extracting secrets or running harmful commands or to prevent accidental pipeline runs, you can require approval for an additional review process. Depending on the enabled option, a pipeline will be put on hold after creation and will only continue after approval. The default restrictive setting is `Approvals for forked repositories`. ## Trusted If you set your project to trusted, a pipeline step and by this the underlying containers gets access to escalated capabilities like mounting volumes. :::note Only server admins can set this option. If you are not a server admin this option won't be shown in your project settings. ::: ## Custom trusted clone plugins During the clone process, Git credentials (e.g., for private repositories) may be required. These credentials are provided via [`netrc`](https://everything.curl.dev/usingcurl/netrc.html). These credentials are injected only into trusted plugins specified in the environment variable `WOODPECKER_PLUGINS_TRUSTED_CLONE` (an instance-wide Woodpecker server setting) or declared in this repository-level setting. With these credentials, it’s possible to perform any Git operations, including pushing changes back to the repo. To prevent unauthorized access or misuse, a plugin allowlist is required, either on the instance level or the repository level. Without an explicit allowlist, a malicious contributor could exploit a custom clone plugin in a Pull Request to reveal or transfer these credentials during the clone step. :::info This setting does not affect subsequent steps, nor does it allow direct pushes to the repository. To enable pushing changes, you can inject Git credentials as a secret or use a dedicated plugin, such as [appleboy/drone-git-push](https://woodpecker-ci.org/plugins/git-push). ::: ## Project visibility You can change the visibility of your project by this setting. If a user has access to a project they can see all builds and their logs and artifacts. Settings, Secrets and Registries can only be accessed by owners. - `Public` Every user can see your project without being logged in. - `Internal` Only authenticated users of the Woodpecker instance can see this project. - `Private` Only you and other owners of the repository can see this project. ## Timeout After this timeout a pipeline has to finish or will be treated as timed out. ## Cancel previous pipelines By enabling this option for a pipeline event previous pipelines of the same event and context will be canceled before starting the newly triggered one. ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/80-badges.md ================================================ # Status Badges Woodpecker has integrated support for repository status badges. These badges can be added to your website or project readme file to display the status of your code. ## Badge endpoint ```uri :///api/badges//status.svg ``` The status badge displays the status for the latest build to your default branch (e.g. main). You can customize the branch by adding the `branch` query parameter. ```diff -:///api/badges//status.svg +:///api/badges//status.svg?branch= ``` By default status badges do not include pull request results, since the status of a pull request does not provide an accurate representation of your repository state. If you'd like to respect other or further events, you can add the `events` query parameter, otherwise the badge represents only the state of the last push event: ```diff -:///api/badges//status.svg +:///api/badges//status.svg?events=manual,cron ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/90-advanced-usage.md ================================================ # Advanced usage ## Advanced YAML syntax YAML has some advanced syntax features that can be used like variables to reduce duplication in your pipeline config: ### Anchors & aliases You can use [YAML anchors & aliases](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) as variables in your pipeline config. To convert this: ```yaml steps: - name: test image: golang:1.18 commands: go test ./... - name: build image: golang:1.18 commands: build ``` Just add a new section called **variables** like this: ```diff +variables: + - &golang_image 'golang:1.18' steps: - name: test - image: golang:1.18 + image: *golang_image commands: go test ./... - name: build - image: golang:1.18 + image: *golang_image commands: build ``` ### Map merges and overwrites ```yaml variables: - &base-plugin-settings target: dist recursive: false try: true - &special-setting special: true - &some-plugin codeberg.org/6543/docker-images/print_env steps: - name: develop image: *some-plugin settings: <<: [*base-plugin-settings, *special-setting] # merge two maps into an empty map when: branch: develop - name: main image: *some-plugin settings: <<: *base-plugin-settings # merge one map and ... try: false # ... overwrite original value ongoing: false # ... adding a new value when: branch: main ``` ### Sequence merges ```yaml variables: pre_cmds: &pre_cmds - echo start - whoami post_cmds: &post_cmds - echo stop hello_cmd: &hello_cmd - echo hello steps: - name: step1 image: debian commands: - <<: *pre_cmds # prepend a sequence - echo exec step now do dedicated things - <<: *post_cmds # append a sequence - name: step2 image: debian commands: - <<: [*pre_cmds, *hello_cmd] # prepend two sequences - echo echo from second step - <<: *post_cmds ``` ### References - [Official YAML specification](https://yaml.org/spec/1.2.2/#3222-anchors-and-aliases) - [YAML cheat sheet](https://learnxinyminutes.com/docs/yaml) ## Persisting environment data between steps One can create a file containing environment variables, and then source it in each step that needs them. ```yaml steps: - name: init image: bash commands: - echo "FOO=hello" >> envvars - echo "BAR=world" >> envvars - name: debug image: bash commands: - source ./envvars - echo $FOO ``` ## Declaring global variables As described in [Global environment variables](./50-environment.md#global-environment-variables), you can define global variables: ```ini WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2 ``` Note that this tightly couples the server and app configurations (where the app is a completely separate application). But this is a good option for truly global variables which should apply to all steps in all pipelines for all apps. ## Docker in docker (dind) setup :::warning This set up will only work on trusted repositories and for security reasons should only be used in private environments. See [project settings](./75-project-settings.md#trusted) to enable "trusted" mode. ::: The snippet below shows how a step can communicate with the docker daemon running in a `docker:dind` service. :::note If your goal is to build/publish OCI images, consider using the [Docker Buildx Plugin](https://woodpecker-ci.org/plugins/docker-buildx) instead. ::: First we need to define a service running a docker with the `dind` tag. This service must run in `privileged` mode: ```yaml services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true ports: - 2376 ``` Next, we need to set up TLS communication between the `dind` service and the step that wants to communicate with the docker daemon (unauthenticated TCP connections have been deprecated [as of docker v27](https://github.com/docker/cli/blob/v27.4.0/docs/deprecated.md#unauthenticated-tcp-connections) and will result in an error in v28). This can be achieved by letting the daemon generate TLS certificates and share them with the client through an agent volume mount (`/opt/woodpeckerci/dind-certs` in the example below). ```diff services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true + environment: + DOCKER_TLS_CERTDIR: /dind-certs + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` In the docker client step: 1. Set the `DOCKER_*` environment variables shown below to configure the connection with the daemon. These generic docker environment variables that are framework-agnostic (e.g. frameworks like [TestContainers](https://testcontainers.com/), [Spring Boot Docker Compose](https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-docker-compose) do all respect them). 2. Mount the volume to the location where the daemon has created the certificates (`/opt/woodpeckerci/dind-certs`) Test the connection with the docker client: ```diff steps: - name: test image: docker:cli # in production use something like 'docker:-cli' + environment: + DOCKER_HOST: "tcp://docker:2376" + DOCKER_CERT_PATH: "/dind-certs/client" + DOCKER_TLS_VERIFY: "1" + volumes: + - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version ``` This step should output the server and client version information if everything has been set up correctly. Full example: ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/dind-certs/client' DOCKER_TLS_VERIFY: '1' volumes: - /opt/woodpeckerci/dind-certs:/dind-certs commands: - docker version services: - name: docker image: docker:dind # use 'docker:-dind' or similar in production privileged: true environment: DOCKER_TLS_CERTDIR: /dind-certs volumes: - /opt/woodpeckerci/dind-certs:/dind-certs ports: - 2376 ``` ================================================ FILE: docs/versioned_docs/version-3.14/20-usage/_category_.yaml ================================================ label: 'Usage' # position: 2 collapsible: true collapsed: false ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/00-general.md ================================================ # General Woodpecker consists of essential components (`server` and `agent`) and an optional component (`autoscaler`). The **server** provides the user interface, processes webhook requests to the underlying forge, serves the API and analyzes the pipeline configurations from the YAML files. The **agent** executes the [workflows](../20-usage/15-terminology/index.md) via a specific [backend](../20-usage/15-terminology/index.md) (Docker, Kubernetes, local) and connects to the server via GRPC. Multiple agents can coexist so that the job limits, choice of backend and other agent-related settings can be fine-tuned for a single instance. The **autoscaler** allows spinning up new VMs on a cloud provider of choice to process pending builds. After the builds finished, the VMs are destroyed again (after a short transition time). :::tip You can add more agents to increase the number of parallel workflows or set the agent's [`WOODPECKER_MAX_WORKFLOWS=1`](./10-configuration/30-agent.md#max_workflows) environment variable to increase the number of parallel workflows per agent. ::: ## Database Woodpecker uses a SQLite database by default, which requires no installation or configuration. For larger instances it is recommended to use it with a Postgres or MariaDB instance. For more details take a look at the [database settings](./10-configuration/10-server.md#databases) page. ## Forge What would a CI/CD system be without any code. By connecting Woodpecker to your [forge](../20-usage/15-terminology/index.md), you can start pipelines on events like pushes or pull requests. Woodpecker will also use your forge to authenticate and report back the status of your pipelines. For more details take a look at the [forge settings](./10-configuration/12-forges/11-overview.md) page. ## Container images :::info No `latest` tag exists to prevent accidental major version upgrades. Either use a SemVer tag or one of the rolling major/minor version tags. Alternatively, the `next` tag can be used for rolling builds from the `main` branch. ::: - `vX.Y.Z`: SemVer tags for specific releases, no entrypoint shell (scratch image) - `vX.Y` - `vX` - `vX.Y.Z-alpine`: SemVer tags for specific releases, rootless for Server and CLI (as of v3.0). - `vX.Y-alpine` - `vX-alpine` - `next`: Built from the `main` branch - `pull_`: Images built from Pull Request branches. Images are pushed to DockerHub and Quay. - woodpecker-server ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-server) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-server)) - woodpecker-agent ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-agent) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-agent)) - woodpecker-cli ([DockerHub](https://hub.docker.com/r/woodpeckerci/woodpecker-cli) or [Quay](https://quay.io/repository/woodpeckerci/woodpecker-cli)) - woodpecker-autoscaler ([DockerHub](https://hub.docker.com/r/woodpeckerci/autoscaler)) ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/05-installation/10-docker-compose.md ================================================ # Docker Compose This example [docker-compose](https://docs.docker.com/compose/) setup shows the deployment of a Woodpecker instance connected to GitHub (`WOODPECKER_GITHUB=true`). If you are using another forge, please change this including the respective secret settings. It creates persistent volumes for the server and agent config directories. The bundled SQLite DB is stored in `/var/lib/woodpecker` and is the most important part to be persisted as it holds all users and repository information. The server uses the default port `8000` and gets exposed to the host here, so WoodpeckerWO can be accessed through this port on the host or by a reverse proxy sitting in front of it. ```yaml title="docker-compose.yaml" services: woodpecker-server: image: woodpeckerci/woodpecker-server:v3 ports: - 8000:8000 volumes: - woodpecker-server-data:/var/lib/woodpecker/ environment: - WOODPECKER_OPEN=true - WOODPECKER_HOST=${WOODPECKER_HOST} - WOODPECKER_GITHUB=true - WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} - WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: image: woodpeckerci/woodpecker-agent:v3 command: agent restart: always depends_on: - woodpecker-server volumes: - woodpecker-agent-config:/etc/woodpecker - /var/run/docker.sock:/var/run/docker.sock environment: - WOODPECKER_SERVER=woodpecker-server:9000 - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} volumes: woodpecker-server-data: woodpecker-agent-config: ``` Woodpecker must know its own address. You must therefore specify the public address in the format `://`. Please omit any trailing slashes: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_HOST=${WOODPECKER_HOST} ``` It is also possible to customize the ports used. Woodpecker uses a separate port for gRPC and for HTTP. The agent makes gRPC calls and connects to the gRPC port. They can be configured with `*_ADDR` variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_GRPC_ADDR=${WOODPECKER_GRPC_ADDR} + - WOODPECKER_SERVER_ADDR=${WOODPECKER_HTTP_ADDR} ``` If the agents establish a connection via the Internet, TLS encryption should be activated for gRPC. The agent must then be configured properly: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: - [...] + - WOODPECKER_GRPC_SECURE=true # defaults to false + - WOODPECKER_GRPC_VERIFY=true # default ``` As agents execute pipeline steps as Docker containers, they require access to the Docker daemon of the host machine: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] + volumes: + - /var/run/docker.sock:/var/run/docker.sock ``` Agents require the server address for communication between agents and servers. The agent connects to the gRPC port of the server: ```diff title="docker-compose.yaml" services: woodpecker-agent: [...] environment: + - WOODPECKER_SERVER=woodpecker-server:9000 ``` The server and the agents use a shared secret to authenticate the communication. This should be a random string, which you should keep secret. You can create such a string with `openssl rand -hex 32`: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} woodpecker-agent: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Handling sensitive data There are several options for handling sensitive data in `docker compose` or `docker swarm` configurations: For Docker Compose, you can use an `.env` file next to your compose configuration to store the secrets outside the compose file. Although this separates the configuration from the secrets, it is still not very secure. Alternatively, you can also use `docker-secrets`. As it can be difficult to use `docker-secrets` for environment variables, Woodpecker allows reading sensitive data from files by providing a `*_FILE` option for all sensitive configuration variables. Woodpecker will then attempt to read the value directly from this file. Note that the original environment variable will overwrite the value read from the file if it is specified at the same time. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_AGENT_SECRET_FILE=/run/secrets/woodpecker-agent-secret + secrets: + - woodpecker-agent-secret + + secrets: + woodpecker-agent-secret: + external: true ``` To store values in a docker secret you can use the following command: ```bash echo "my_agent_secret_key" | docker secret create woodpecker-agent-secret - ``` ## SELinux Considerations If you're running Woodpecker on a system with SELinux enabled (RHEL, CentOS, Fedora, etc.), you may need to add the `:z` or `:Z` option to volume mounts. For the Docker socket volume: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock:z ``` For more details and other SELinux-related solutions, see the [Troubleshooting](../../20-usage/100-troubleshooting.md#selinux-issues) page. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/05-installation/20-helm-chart.md ================================================ # Helm Chart Woodpecker provides a [Helm chart](https://github.com/woodpecker-ci/helm) for Kubernetes environments: ```bash helm install woodpecker oci://ghcr.io/woodpecker-ci/helm/woodpecker --version ``` ## Metrics To enable metrics gathering, set the following in values.yml: ```yaml metrics: enabled: true port: 9001 ``` This activates the `/metrics` endpoint on port `9001` without authentication. This port is not exposed externally by default. Use the instructions at Prometheus if you want to enable authenticated external access to metrics. To enable both Prometheus pod monitoring discovery, set: ```yaml prometheus: podmonitor: enabled: true interval: 60s labels: {} ``` If you are not receiving metrics after following the steps above, verify that your Prometheus configuration includes your namespace explicitly in the podMonitorNamespaceSelector or that the selectors are disabled: ```yaml # Search all available namespaces podMonitorNamespaceSelector: matchLabels: {} # Enable all available pod monitors podMonitorSelector: matchLabels: {} ``` ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/05-installation/30-packages.md ================================================ # Distribution packages ## Official packages - DEB - RPM The pre-built packages are available on the [GitHub releases](https://github.com/woodpecker-ci/woodpecker/releases/latest) page. The packages can be installed using the package manager of your distribution. ```Shell RELEASE_VERSION=$(curl -s https://api.github.com/repos/woodpecker-ci/woodpecker/releases/latest | grep -Po '"tag_name":\s"v\K[^"]+') # Debian/Ubuntu (x86_64) curl -fLOOO "https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb" sudo apt --fix-broken install ./woodpecker-{server,agent,cli}_${RELEASE_VERSION}_amd64.deb # CentOS/RHEL (x86_64) sudo dnf install https://github.com/woodpecker-ci/woodpecker/releases/download/v${RELEASE_VERSION}/woodpecker-{server,agent,cli}-${RELEASE_VERSION}.x86_64.rpm ``` The package installation will create a systemd service file for the Woodpecker server and agent along with an example environment file. To configure the server, copy the example environment file `/etc/woodpecker/woodpecker-server.env.example` to `/etc/woodpecker/woodpecker-server.env` and adjust the values. ```ini title="/usr/local/lib/systemd/system/woodpecker-server.service" [Unit] Description=WoodpeckerCI server Documentation=https://woodpecker-ci.org/docs/administration/server-config Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env ConditionPathExists=/etc/woodpecker/woodpecker-server.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-server.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-server WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-server.env" WOODPECKER_OPEN=true WOODPECKER_HOST=${WOODPECKER_HOST} WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=${WOODPECKER_GITHUB_CLIENT} WOODPECKER_GITHUB_SECRET=${WOODPECKER_GITHUB_SECRET} WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` After installing the agent, copy the example environment file `/etc/woodpecker/woodpecker-agent.env.example` to `/etc/woodpecker/woodpecker-agent.env` and adjust the values as well. The agent will automatically register itself with the server. ```ini title="/usr/local/lib/systemd/system/woodpecker-agent.service" [Unit] Description=WoodpeckerCI agent Documentation=https://woodpecker-ci.org/docs/administration/configuration/agent Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env ConditionPathExists=/etc/woodpecker/woodpecker-agent.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-agent.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-agent WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ``` ```shell title="/etc/woodpecker/woodpecker-agent.env" WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET} ``` ## Community packages :::info Woodpecker itself is not responsible for creating these packages. Please reach out to the people responsible for packaging Woodpecker for the individual distributions. ::: - [Alpine (Edge)](https://pkgs.alpinelinux.org/packages?name=woodpecker&branch=edge&repo=&arch=&maintainer=) - [Arch Linux](https://archlinux.org/packages/?q=woodpecker) - [openSUSE](https://software.opensuse.org/package/woodpecker) - [YunoHost](https://apps.yunohost.org/app/woodpecker) - [Cloudron](https://www.cloudron.io/store/org.woodpecker_ci.cloudronapp.html) - [Easypanel](https://easypanel.io/docs/templates/woodpeckerci) - [Homebrew](https://formulae.brew.sh/formula/woodpecker-cli) (CLI only) ### NixOS :::info This module is not maintained by the Woodpecker developers. If you experience issues please open a bug report in the [nixpkgs repo](https://github.com/NixOS/nixpkgs/issues/new/choose) where the module is maintained. ::: In theory, the NixOS installation is very similar to the binary installation and supports multiple backends. In practice, the settings are specified declaratively in the NixOS configuration and no manual steps need to be taken. ```nix { config , ... }: let domain = "woodpecker.example.org"; in { # This automatically sets up certificates via let's encrypt security.acme.defaults.email = "acme@example.com"; security.acme.acceptTerms = true; # Setting up a nginx proxy that handles tls for us services.nginx = { enable = true; openFirewall = true; recommendedTlsSettings = true; recommendedOptimisation = true; recommendedProxySettings = true; virtualHosts."${domain}" = { enableACME = true; forceSSL = true; locations."/".proxyPass = "http://localhost:3007"; }; }; services.woodpecker-server = { enable = true; environment = { WOODPECKER_HOST = "https://${domain}"; WOODPECKER_SERVER_ADDR = ":3007"; WOODPECKER_OPEN = "true"; }; # You can pass a file with env vars to the system it could look like: # WOODPECKER_AGENT_SECRET=XXXXXXXXXXXXXXXXXXXXXX environmentFile = "/path/to/my/secrets/file"; }; # This sets up a woodpecker agent services.woodpecker-agents.agents."docker" = { enable = true; # We need this to talk to the podman socket extraGroups = [ "podman" ]; environment = { WOODPECKER_SERVER = "localhost:9000"; WOODPECKER_MAX_WORKFLOWS = "4"; DOCKER_HOST = "unix:///run/podman/podman.sock"; WOODPECKER_BACKEND = "docker"; }; # Same as with woodpecker-server environmentFile = [ "/var/lib/secrets/woodpecker.env" ]; }; # Here we setup podman and enable dns virtualisation.podman = { enable = true; defaultNetwork.settings = { dns_enabled = true; }; }; # This is needed for podman to be able to talk over dns networking.firewall.interfaces."podman0" = { allowedUDPPorts = [ 53 ]; allowedTCPPorts = [ 53 ]; }; } ``` All configuration options can be found via [NixOS Search](https://search.nixos.org/options?channel=unstable&size=200&sort=relevance&query=woodpecker). There are also some additional resources on how to utilize Woodpecker more effectively with NixOS on the [Awesome Woodpecker](/awesome) page, like using the runners nix-store in the pipeline. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/05-installation/_category_.yaml ================================================ label: 'Installation' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/10-server.md ================================================ --- toc_max_heading_level: 3 --- # Server ## Forge and User configuration Woodpecker does not have its own user registration. Users are provided by your [forge](./12-forges/11-overview.md) (using OAuth2). The registration is closed by default (`WOODPECKER_OPEN=false`). If the registration is open, any user with an account can log in to Woodpecker with the configured forge. You can also restrict the registration: - closed registration and manually managing users with the CLI `woodpecker-cli user` - open registration and allowing certain admin users with the setting `WOODPECKER_ADMIN` ```ini WOODPECKER_OPEN=false WOODPECKER_ADMIN=john.smith,jane_doe ``` - open registration and filtering by organizational affiliation with the setting `WOODPECKER_ORGS` ```ini WOODPECKER_OPEN=true WOODPECKER_ORGS=dolores,dog-patch ``` Administrators should also be explicitly set in your configuration. ```ini WOODPECKER_ADMIN=john.smith,jane_doe ``` ## Repository configuration Woodpecker works with the user's OAuth permissions on the forge. By default Woodpecker will synchronize all repositories the user has access to. Use the variable `WOODPECKER_REPO_OWNERS` to filter which repos should only be synchronized by GitHub users. Normally you should enter the GitHub name of your company here. ```ini WOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user ``` ## Databases The default database engine of Woodpecker is an embedded SQLite database which requires zero installation or configuration. But you can replace it with a MySQL/MariaDB or PostgreSQL database. There are also some fundamentals to keep in mind: - Woodpecker does not create your database automatically. If you are using the MySQL or Postgres driver you will need to manually create your database using `CREATE DATABASE`. - Woodpecker does not perform data archival; it considered out-of-scope for the project. Woodpecker is rather conservative with the amount of data it stores, however, you should expect the database logs to grow the size of your database considerably. - Woodpecker automatically handles database migration, including the initial creation of tables and indexes. New versions of Woodpecker will automatically upgrade the database unless otherwise specified in the release notes. - Woodpecker does not perform database backups. This should be handled by separate third party tools provided by your database vendor of choice. ### SQLite By default Woodpecker uses a SQLite database stored under `/var/lib/woodpecker/`. If using containers, you can mount a [data volume](https://docs.docker.com/storage/volumes/#create-and-manage-volumes) to persist the SQLite database. ```diff title="docker-compose.yaml" services: woodpecker-server: [...] + volumes: + - woodpecker-server-data:/var/lib/woodpecker/ ``` ### MySQL/MariaDB The below example demonstrates MySQL database configuration. See the official driver [documentation](https://github.com/go-sql-driver/mysql#dsn-data-source-name) for configuration options and examples. The minimum version of MySQL/MariaDB required is determined by the `go-sql-driver/mysql` - see [it's README](https://github.com/go-sql-driver/mysql#requirements) for more information. ```ini WOODPECKER_DATABASE_DRIVER=mysql WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true ``` ### PostgreSQL The below example demonstrates Postgres database configuration. See the official driver [documentation](https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING) for configuration options and examples. Please use Postgres versions equal or higher than **11**. ```ini WOODPECKER_DATABASE_DRIVER=postgres WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/postgres?sslmode=disable ``` ## TLS Woodpecker supports SSL configuration by mounting certificates into your container. ```ini WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key ``` TLS support is provided using the [ListenAndServeTLS](https://golang.org/pkg/net/http/#ListenAndServeTLS) function from the Go standard library. ### Container configuration In addition to the ports shown in the [docker-compose](../05-installation/10-docker-compose.md) installation, port `443` must be exposed: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] ports: + - 80:80 + - 443:443 - 9000:9000 ``` Additionally, the certificate and key must be mounted and referenced: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: + - WOODPECKER_SERVER_CERT=/etc/certs/woodpecker.example.com/server.crt + - WOODPECKER_SERVER_KEY=/etc/certs/woodpecker.example.com/server.key volumes: + - /etc/certs/woodpecker.example.com/server.crt:/etc/certs/woodpecker.example.com/server.crt + - /etc/certs/woodpecker.example.com/server.key:/etc/certs/woodpecker.example.com/server.key ``` ## Reverse Proxy ### Apache This guide provides a brief overview for installing Woodpecker server behind the Apache2 web-server. This is an example configuration: ```apacheconf ProxyPreserveHost On RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` You must have these Apache modules installed: - `proxy` - `proxy_http` You must configure Apache to set `X-Forwarded-Proto` when using https. ```diff ProxyPreserveHost On +RequestHeader set X-Forwarded-Proto "https" ProxyPass / http://127.0.0.1:8000/ ProxyPassReverse / http://127.0.0.1:8000/ ``` ### Nginx This guide provides a basic overview for installing Woodpecker server behind the Nginx web-server. For more advanced configuration options please consult the official Nginx [documentation](https://docs.nginx.com/nginx/admin-guide). Example configuration: ```nginx server { listen 80; server_name woodpecker.example.com; location / { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` You must configure the proxy to set `X-Forwarded` proxy headers: ```diff server { listen 80; server_name woodpecker.example.com; location / { + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://127.0.0.1:8000; proxy_redirect off; proxy_http_version 1.1; proxy_buffering off; chunked_transfer_encoding off; } } ``` ### Caddy This guide provides a brief overview for installing Woodpecker server behind the [Caddy web-server](https://caddyserver.com/). This is an example caddyfile proxy configuration: ```caddy # expose WebUI and API woodpecker.example.com { reverse_proxy woodpecker-server:8000 } # expose gRPC woodpecker-agent.example.com { reverse_proxy h2c://woodpecker-server:9000 } ``` ### Tunnelmole [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client) is an open source tunneling tool. Start by [installing tunnelmole](https://github.com/robbie-cahill/tunnelmole-client#installation). After the installation, run the following command to start tunnelmole: ```bash tmole 8000 ``` It will start a tunnel and will give a response like this: ```bash ➜ ~ tmole 8000 http://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 https://bvdo5f-ip-49-183-170-144.tunnelmole.net is forwarding to localhost:8000 ``` Set `WOODPECKER_HOST` to the Tunnelmole URL (`xxx.tunnelmole.net`) and start the server. ### Ngrok [Ngrok](https://ngrok.com/) is a popular closed source tunnelling tool. After installing ngrok, open a new console and run the following command: ```bash ngrok http 8000 ``` Set `WOODPECKER_HOST` to the ngrok URL (usually xxx.ngrok.io) and start the server. ### Traefik To install the Woodpecker server behind a [Traefik](https://traefik.io/) load balancer, you must expose both the `http` and the `gRPC` ports. Here is a comprehensive example, considering you are running Traefik with docker swarm and want to do TLS termination and automatic redirection from http to https. ```yaml services: server: image: woodpeckerci/woodpecker-server:latest environment: - WOODPECKER_OPEN=true - WOODPECKER_ADMIN=your_admin_user # other settings ... networks: - dmz # externally defined network, so that traefik can connect to the server volumes: - woodpecker-server-data:/var/lib/woodpecker/ deploy: labels: - traefik.enable=true # web server - traefik.http.services.woodpecker-service.loadbalancer.server.port=8000 - traefik.http.routers.woodpecker-secure.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker-secure.tls=true - traefik.http.routers.woodpecker-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-secure.service=woodpecker-service - traefik.http.routers.woodpecker.rule=Host(`ci.example.com`) - traefik.http.routers.woodpecker.entrypoints=web - traefik.http.routers.woodpecker.service=woodpecker-service - traefik.http.middlewares.woodpecker-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker.middlewares=woodpecker-redirect@docker # gRPC service - traefik.http.services.woodpecker-grpc.loadbalancer.server.port=9000 - traefik.http.services.woodpecker-grpc.loadbalancer.server.scheme=h2c - traefik.http.routers.woodpecker-grpc-secure.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc-secure.tls=true - traefik.http.routers.woodpecker-grpc-secure.tls.certresolver=letsencrypt - traefik.http.routers.woodpecker-grpc-secure.entrypoints=web-secure - traefik.http.routers.woodpecker-grpc-secure.service=woodpecker-grpc - traefik.http.routers.woodpecker-grpc.rule=Host(`woodpecker-grpc.example.com`) - traefik.http.routers.woodpecker-grpc.entrypoints=web - traefik.http.routers.woodpecker-grpc.service=woodpecker-grpc - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.scheme=https - traefik.http.middlewares.woodpecker-grpc-redirect.redirectscheme.permanent=true - traefik.http.routers.woodpecker-grpc.middlewares=woodpecker-grpc-redirect@docker volumes: woodpecker-server-data: driver: local networks: dmz: external: true ``` ## Metrics ### Endpoint Woodpecker is compatible with Prometheus and exposes a `/metrics` endpoint if the environment variable `WOODPECKER_PROMETHEUS_AUTH_TOKEN` is set. Please note that access to the metrics endpoint is restricted and requires the authorization token from the environment variable mentioned above. ```yaml global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` ### Authorization An administrator will need to generate a user API token and configure in the Prometheus configuration file as a bearer token. Please see the following example: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token: dummyToken... static_configs: - targets: ['woodpecker.domain.com'] ``` As an alternative, the token can also be read from a file: ```diff global: scrape_interval: 60s scrape_configs: - job_name: 'woodpecker' + bearer_token_file: /etc/secrets/woodpecker-monitoring-token static_configs: - targets: ['woodpecker.domain.com'] ``` ### Reference List of Prometheus metrics specific to Woodpecker: ```yaml # HELP woodpecker_pipeline_count Pipeline count. # TYPE woodpecker_pipeline_count counter woodpecker_pipeline_count{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 woodpecker_pipeline_count{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 3 # HELP woodpecker_pipeline_time Build time. # TYPE woodpecker_pipeline_time gauge woodpecker_pipeline_time{branch="main",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 116 woodpecker_pipeline_time{branch="dev",pipeline="total",repo="woodpecker-ci/woodpecker",status="success"} 155 # HELP woodpecker_pipeline_total_count Total number of builds. # TYPE woodpecker_pipeline_total_count gauge woodpecker_pipeline_total_count 1025 # HELP woodpecker_pending_steps Total number of pending pipeline steps. # TYPE woodpecker_pending_steps gauge woodpecker_pending_steps 0 # HELP woodpecker_repo_count Total number of repos. # TYPE woodpecker_repo_count gauge woodpecker_repo_count 9 # HELP woodpecker_running_steps Total number of running pipeline steps. # TYPE woodpecker_running_steps gauge woodpecker_running_steps 0 # HELP woodpecker_user_count Total number of users. # TYPE woodpecker_user_count gauge woodpecker_user_count 1 # HELP woodpecker_waiting_steps Total number of pipeline waiting on deps. # TYPE woodpecker_waiting_steps gauge woodpecker_waiting_steps 0 # HELP woodpecker_worker_count Total number of workers. # TYPE woodpecker_worker_count gauge woodpecker_worker_count 4 ``` #### Example response structure ```json { "configs": [ { "name": "central-override", "data": "steps:\n - name: backend\n image: alpine\n commands:\n - echo \"Hello there from ConfigAPI\"\n" } ] } ``` ## UI customization Woodpecker supports custom JS and CSS files. These files must be present in the server's filesystem. They can be backed in a Docker image or mounted from a ConfigMap inside a Kubernetes environment. The configuration variables are independent of each other, which means it can be just one file present, or both. ```ini WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js ``` The examples below show how to place a banner message in the top navigation bar of Woodpecker. ```css title="woodpecker.css" .banner-message { position: absolute; width: 280px; height: 40px; margin-left: 240px; margin-top: 5px; padding-top: 5px; font-weight: bold; background: red no-repeat; text-align: center; } ``` ```javascript title="woodpecker.js" // place/copy a minified version of your preferred lightweight JavaScript library here ... !(function () { 'use strict'; function e() {} /*...*/ })(); $().ready(function () { $('.app nav img').first().htmlAfter(""); }); ``` ## Environment variables ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### LOG_FILE - Name: `WOODPECKER_LOG_FILE` - Default: `stderr` Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. --- ### DATABASE_LOG - Name: `WOODPECKER_DATABASE_LOG` - Default: `false` Enable logging in database engine (currently xorm). --- ### DATABASE_LOG_SQL - Name: `WOODPECKER_DATABASE_LOG_SQL` - Default: `false` Enable logging of sql commands. --- ### DATABASE_MAX_CONNECTIONS - Name: `WOODPECKER_DATABASE_MAX_CONNECTIONS` - Default: `100` Max database connections xorm is allowed create. --- ### DATABASE_IDLE_CONNECTIONS - Name: `WOODPECKER_DATABASE_IDLE_CONNECTIONS` - Default: `2` Amount of database connections xorm will hold open. --- ### DATABASE_CONNECTION_TIMEOUT - Name: `WOODPECKER_DATABASE_CONNECTION_TIMEOUT` - Default: `3 Seconds` Time an active database connection is allowed to stay open. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOST - Name: `WOODPECKER_HOST` - Default: none Server fully qualified URL of the user-facing hostname, port (if not default for HTTP/HTTPS) and path prefix. Examples: - `WOODPECKER_HOST=http://woodpecker.example.org` - `WOODPECKER_HOST=http://example.org/woodpecker` - `WOODPECKER_HOST=http://example.org:1234/woodpecker` --- ### SERVER_ADDR - Name: `WOODPECKER_SERVER_ADDR` - Default: `:8000` Configures the HTTP listener port. --- ### SERVER_ADDR_TLS - Name: `WOODPECKER_SERVER_ADDR_TLS` - Default: `:443` Configures the HTTPS listener port when SSL is enabled. --- ### SERVER_CERT - Name: `WOODPECKER_SERVER_CERT` - Default: none Path to an SSL certificate used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_CERT=/path/to/cert.pem` --- ### SERVER_KEY - Name: `WOODPECKER_SERVER_KEY` - Default: none Path to an SSL certificate key used by the server to accept HTTPS requests. Example: `WOODPECKER_SERVER_KEY=/path/to/key.pem` --- ### CUSTOM_CSS_FILE - Name: `WOODPECKER_CUSTOM_CSS_FILE` - Default: none File path for the server to serve a custom .CSS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_CSS_FILE=/usr/local/www/woodpecker.css` --- ### CUSTOM_JS_FILE - Name: `WOODPECKER_CUSTOM_JS_FILE` - Default: none File path for the server to serve a custom .JS file, used for customizing the UI. Can be used for showing banner messages, logos, or environment-specific hints (a.k.a. white-labeling). The file must be UTF-8 encoded, to ensure all special characters are preserved. Example: `WOODPECKER_CUSTOM_JS_FILE=/usr/local/www/woodpecker.js` --- ### GRPC_ADDR - Name: `WOODPECKER_GRPC_ADDR` - Default: `:9000` Configures the gRPC listener port. --- ### GRPC_SECRET - Name: `WOODPECKER_GRPC_SECRET` - Default: `secret` Configures the gRPC JWT secret. --- ### GRPC_SECRET_FILE - Name: `WOODPECKER_GRPC_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GRPC_SECRET` from the specified filepath. --- ### METRICS_SERVER_ADDR - Name: `WOODPECKER_METRICS_SERVER_ADDR` - Default: none Configures an unprotected metrics endpoint. An empty value disables the metrics endpoint completely. Example: `:9001` --- ### ADMIN - Name: `WOODPECKER_ADMIN` - Default: none Comma-separated list of admin accounts. Example: `WOODPECKER_ADMIN=user1,user2` --- ### ORGS - Name: `WOODPECKER_ORGS` - Default: none Comma-separated list of approved organizations. Example: `org1,org2` --- ### REPO_OWNERS - Name: `WOODPECKER_REPO_OWNERS` - Default: none Repositories by those owners will be allowed to be used in woodpecker. Example: `user1,user2` --- ### OPEN - Name: `WOODPECKER_OPEN` - Default: `false` Enable to allow user registration. --- ### AUTHENTICATE_PUBLIC_REPOS - Name: `WOODPECKER_AUTHENTICATE_PUBLIC_REPOS` - Default: `false` Always use authentication to clone repositories even if they are public. Needed if the forge requires to always authenticate as used by many companies. --- ### DEFAULT_ALLOW_PULL_REQUESTS - Name: `WOODPECKER_DEFAULT_ALLOW_PULL_REQUESTS` - Default: `true` The default setting for allowing pull requests on a repo. --- ### DEFAULT_APPROVAL_MODE - Name: `WOODPECKER_DEFAULT_APPROVAL_MODE` - Default: `forks` The default setting for the approval mode on a repo. Possible values: `none`, `forks`, `pull_requests` or `all_events`. --- ### DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS - Name: `WOODPECKER_DEFAULT_CANCEL_PREVIOUS_PIPELINE_EVENTS` - Default: `pull_request, push` List of event names that will be canceled when a new pipeline for the same context (tag, branch) is created. --- ### DEFAULT_CLONE_PLUGIN - Name: `WOODPECKER_DEFAULT_CLONE_PLUGIN` - Default: `docker.io/woodpeckerci/plugin-git` The default docker image to be used when cloning the repo. It is also added to the trusted clone plugin list. ### DEFAULT_WORKFLOW_LABELS - Name: `WOODPECKER_DEFAULT_WORKFLOW_LABELS` - Default: none You can specify default label/platform conditions that will be used for agent selection for workflows that does not have labels conditions set. Example: `platform=linux/amd64,backend=docker` ### DEFAULT_PIPELINE_TIMEOUT - Name: `WOODPECKER_DEFAULT_PIPELINE_TIMEOUT` - Default: 60 The default time for a repo in minutes before a pipeline gets killed ### MAX_PIPELINE_TIMEOUT - Name: `WOODPECKER_MAX_PIPELINE_TIMEOUT` - Default: 120 The maximum time in minutes you can set in the repo settings before a pipeline gets killed --- ### SESSION_EXPIRES - Name: `WOODPECKER_SESSION_EXPIRES` - Default: `72h` Configures the session expiration time. Context: when someone does log into Woodpecker, a temporary session token is created. As long as the session is valid (until it expires or log-out), a user can log into Woodpecker, without re-authentication. ### PLUGINS_PRIVILEGED - Name: `WOODPECKER_PLUGINS_PRIVILEGED` - Default: none Docker images to run in privileged mode. Only change if you are sure what you do! You should specify the tag of your images too, as this enforces exact matches. ### PLUGINS_TRUSTED_CLONE - Name: `WOODPECKER_PLUGINS_TRUSTED_CLONE` - Default: `docker.io/woodpeckerci/plugin-git,docker.io/woodpeckerci/plugin-git,quay.io/woodpeckerci/plugin-git` Plugins which are trusted to handle the Git credential info in clone steps. If a clone step use an image not in this list, Git credentials will not be injected and users have to use other methods (e.g. secrets) to clone non-public repos. You should specify the tag of your images too, as this enforces exact matches. --- ### DOCKER_CONFIG - Name: `WOODPECKER_DOCKER_CONFIG` - Default: none Configures a specific private registry config for all pipelines. Example: `WOODPECKER_DOCKER_CONFIG=/home/user/.docker/config.json` --- ### ENVIRONMENT - Name: `WOODPECKER_ENVIRONMENT` - Default: none If you want specific environment variables to be available in all of your pipelines use the `WOODPECKER_ENVIRONMENT` setting on the Woodpecker server. Note that these can't overwrite any existing, built-in variables. Example: `WOODPECKER_ENVIRONMENT=first_var:value1,second_var:value2` --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath --- ### DISABLE_USER_AGENT_REGISTRATION - Name: `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION` - Default: false By default, users can create new agents for their repos they have admin access to. If an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements. :::note You should set this option if you have, for example, global secrets and don't trust your users to create a rogue agent and pipeline for secret extraction. ::: --- ### KEEPALIVE_MIN_TIME - Name: `WOODPECKER_KEEPALIVE_MIN_TIME` - Default: none Server-side enforcement policy on the minimum amount of time a client should wait before sending a keepalive ping. Example: `WOODPECKER_KEEPALIVE_MIN_TIME=10s` --- ### DATABASE_DRIVER - Name: `WOODPECKER_DATABASE_DRIVER` - Default: `sqlite3` The database driver name. Possible values are `sqlite3`, `mysql` or `postgres`. --- ### DATABASE_DATASOURCE - Name: `WOODPECKER_DATABASE_DATASOURCE` - Default: `woodpecker.sqlite` if not running inside a container, `/var/lib/woodpecker/woodpecker.sqlite` if running inside a container The database connection string. The default value is the path of the embedded SQLite database file. Example: ```bash # MySQL # https://github.com/go-sql-driver/mysql#dsn-data-source-name WOODPECKER_DATABASE_DATASOURCE=root:password@tcp(1.2.3.4:3306)/woodpecker?parseTime=true # PostgreSQL # https://www.postgresql.org/docs/current/static/libpq-connect.html#LIBPQ-CONNSTRING WOODPECKER_DATABASE_DATASOURCE=postgres://root:password@1.2.3.4:5432/woodpecker?sslmode=disable ``` --- ### DATABASE_DATASOURCE_FILE - Name: `WOODPECKER_DATABASE_DATASOURCE_FILE` - Default: none Read the value for `WOODPECKER_DATABASE_DATASOURCE` from the specified filepath --- ### PROMETHEUS_AUTH_TOKEN - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN` - Default: none Token to secure the Prometheus metrics endpoint. Must be set to enable the endpoint. --- ### PROMETHEUS_AUTH_TOKEN_FILE - Name: `WOODPECKER_PROMETHEUS_AUTH_TOKEN_FILE` - Default: none Read the value for `WOODPECKER_PROMETHEUS_AUTH_TOKEN` from the specified filepath --- ### STATUS_CONTEXT - Name: `WOODPECKER_STATUS_CONTEXT` - Default: `ci/woodpecker` Context prefix Woodpecker will use to publish status messages to SCM. You probably will only need to change it if you run multiple Woodpecker instances for a single repository. --- ### STATUS_CONTEXT_FORMAT - Name: `WOODPECKER_STATUS_CONTEXT_FORMAT` - Default: `{{ .context }}/{{ .event }}/{{ .workflow }}{{if not (eq .axis_id 0)}}/{{.axis_id}}{{end}}` Template for the status messages published to forges, uses [Go templates](https://pkg.go.dev/text/template) as template language. Supported variables: - `context`: Woodpecker's context (see `WOODPECKER_STATUS_CONTEXT`) - `event`: the event which started the pipeline - `workflow`: the workflow's name - `owner`: the repo's owner - `repo`: the repo's name --- ### CONFIG_EXTENSION_ENDPOINT - Name: `WOODPECKER_CONFIG_EXTENSION_ENDPOINT` - Default: none Specify a configuration extension endpoint, see [Configuration Extension](../../20-usage/72-extensions/40-configuration-extension.md) --- ### CONFIG_EXTENSION_EXCLUSIVE - Name: `CONFIG_EXTENSION_EXCLUSIVE` - Default: false Whether the forge request should be skipped for the global configuration endpoint. :::warning If you enable this, all repos will exclusively use the global config service endpoint. There is no possibility to directly define pipelines in the forge, except the extension handles this case itself as well. ::: --- ### CONFIG_EXTENSION_NETRC - Name: `WOODPECKER_CONFIG_EXTENSION_NETRC` - Default: false Send `netrc` to the config extension endpoint. :::warning The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository. ::: --- ### SECRET_EXTENSION_ENDPOINT - Name: `WOODPECKER_SECRET_EXTENSION_ENDPOINT` - Default: none Specify a secret extension endpoint, see [Secret Extension](../../20-usage/72-extensions/55-secret-extension.md) --- ### SECRET_EXTENSION_NETRC - Name: `WOODPECKER_SECRET_EXTENSION_NETRC` - Default: false Send `netrc` to the secret extension endpoint. :::warning The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository. ::: --- ### REGISTRY_EXTENSION_ENDPOINT - Name: `WOODPECKER_REGISTRY_EXTENSION_ENDPOINT` - Default: none Specify a registry extension endpoint, see [Registry Extension](../../20-usage/72-extensions/50-registry-extension.md) --- ### REGISTRY_EXTENSION_NETRC - Name: `WOODPECKER_REGISTRY_EXTENSION_NETRC` - Default: false Send `netrc` to the registry extension endpoint. :::warning The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository. ::: --- ### EXTENSIONS_ALLOWED_HOSTS - Name: `WOODPECKER_EXTENSIONS_ALLOWED_HOSTS` - Default: `external` Comma-separated list of hosts that are allowed to be contacted by extensions. Possible values are `loopback`, `private`, `external`, `*` or CIDR list. --- ### FORGE_TIMEOUT - Name: `WOODPECKER_FORGE_TIMEOUT` - Default: 5s Specify timeout when fetching the Woodpecker configuration from forge. See for syntax reference. --- ### FORGE_RETRY - Name: `WOODPECKER_FORGE_RETRY` - Default: 3 Specify how many retries of fetching the Woodpecker configuration from a forge are done before we fail. --- ### ENABLE_SWAGGER - Name: `WOODPECKER_ENABLE_SWAGGER` - Default: true Enable the Swagger UI for API documentation. --- ### DISABLE_VERSION_CHECK - Name: `WOODPECKER_DISABLE_VERSION_CHECK` - Default: false Disable version check in admin web UI. --- ### LOG_STORE - Name: `WOODPECKER_LOG_STORE` - Default: `database` Where to store logs. Possible values: - `database`: stores the logs in the database - `file`: stores logs in JSON files on the files system - `addon`: uses an [addon](./100-addons.md#log) to store logs --- ### LOG_STORE_FILE_PATH - Name: `WOODPECKER_LOG_STORE_FILE_PATH` - Default: none If [`WOODPECKER_LOG_STORE`](#log_store) is: - `file`: Directory to store logs in - `addon`: The path to the addon executable --- ### EXPERT_WEBHOOK_HOST - Name: `WOODPECKER_EXPERT_WEBHOOK_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified Woodpecker server URL, called by the webhooks of the forge. Format: `://[/]`. --- ### EXPERT_FORGE_OAUTH_HOST - Name: `WOODPECKER_EXPERT_FORGE_OAUTH_HOST` - Default: none :::warning This option is not required in most cases and should only be used if you know what you're doing. ::: Fully qualified public forge URL, used if forge url is not a public URL. Format: `://[/]`. --- ### FORCE_IGNORE_SERVICE_FAILURE - Name: `WOODPECKER_FORCE_IGNORE_SERVICE_FAILURE` - Default: true :::warning Since v3.14.0, Woodpecker can report the status of services and detached steps. Because these can now fail, until v4.0.0 is released, service failures are ignored by default to preserve backward compatibility. We encourage you to disable this option and update your pipeline configuration. ::: --- ### GITHUB\_\* See [GitHub configuration](./12-forges/20-github.md#configuration) --- ### GITEA\_\* See [Gitea configuration](./12-forges/30-gitea.md#configuration) --- ### BITBUCKET\_\* See [Bitbucket configuration](./12-forges/50-bitbucket.md#configuration) --- ### GITLAB\_\* See [GitLab configuration](./12-forges/40-gitlab.md#configuration) ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/100-addons.md ================================================ # Addons Addons can be used to extend the Woodpecker server. Currently, they can be used for forges and the log service. :::warning Addon forges are still experimental. Their implementation can change and break at any time. ::: :::danger You must trust the author of the addon forge you are using. They may have access to authentication codes and other potentially sensitive information. ::: ## Usage To use an addon forge, download the correct addon version. ### Forge Use this in your `.env`: ```ini WOODPECKER_ADDON_FORGE=/path/to/your/addon/forge/file ``` In case you run Woodpecker as container, you probably want to mount the addon binary to `/opt/addons/`. #### List of addon forges - [Radicle](https://radicle.xyz/): Open source, peer-to-peer code collaboration stack built on Git. Radicle addon for Woodpecker CI can be found at [this repo](https://explorer.radicle.gr/nodes/seed.radicle.gr/rad:z39Cf1XzrvCLRZZJRUZnx9D1fj5ws). ### Log Use this in your `.env`: ```ini WOODPECKER_LOG_STORE=addon WOODPECKER_LOG_STORE_FILE_PATH=/path/to/your/addon/forge/file ``` ## Developing addon forges See [Addons](../../92-development/100-addons.md). ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/10-docker.md ================================================ --- toc_max_heading_level: 2 --- # Docker This is the original backend used with Woodpecker. The docker backend executes each step inside a separate container started on the agent. ## Private registries Woodpecker supports [Docker credentials](https://github.com/docker/docker-credential-helpers) to securely store registry credentials. Install your corresponding credential helper and configure it in your Docker config file passed via [`WOODPECKER_DOCKER_CONFIG`](../10-server.md#docker_config). To add your credential helper to the Woodpecker server container you could use the following code to build a custom image: ```dockerfile FROM woodpeckerci/woodpecker-server:latest-alpine RUN apk add -U --no-cache docker-credential-ecr-login ``` ## Step specific configuration ### Run user By default the docker backend starts the step container without the `--user` flag. This means the step container will use the default user of the container. To change this behavior you can set the `user` backend option to the preferred user/group: ```yaml steps: - name: example image: alpine commands: - whoami backend_options: docker: user: 65534:65534 ``` The syntax is the same as the [docker run](https://docs.docker.com/engine/reference/run/#user) `--user` flag. ## Tips and tricks ### Image cleanup The agent **will not** automatically remove images from the host. This task should be managed by the host system. For example, you can use a cron job to periodically do clean-up tasks for the CI runner. :::danger The following commands **are destructive** and **irreversible** it is highly recommended that you test these commands on your system before running them in production via a cron job or other automation. ::: - Remove all unused images ```bash docker image rm $(docker images --filter "dangling=true" -q --no-trunc) ``` - Remove Woodpecker volumes ```bash docker volume rm $(docker volume ls --filter name=^wp_* --filter dangling=true -q) ``` ### Podman There is no official support for Podman, but one can try to set the environment variable `DOCKER_HOST` to point to the Podman socket. It might work. See also the [Blog posts](https://woodpecker-ci.org/blog). ## Environment variables ### BACKEND_DOCKER_NETWORK - Name: `WOODPECKER_BACKEND_DOCKER_NETWORK` - Default: none Set to the name of an existing network which will be attached to all your pipeline containers (steps). Please be careful as this allows the containers of different pipelines to access each other! --- ### BACKEND_DOCKER_ENABLE_IPV6 - Name: `WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6` - Default: `false` Enable IPv6 for the networks used by pipeline containers (steps). Make sure you configured your docker daemon to support IPv6. --- ### BACKEND_DOCKER_VOLUMES - Name: `WOODPECKER_BACKEND_DOCKER_VOLUMES` - Default: none List of default volumes separated by comma to be mounted to all pipeline containers (steps). For example to use custom CA certificates installed on host and host timezone use `/etc/ssl/certs:/etc/ssl/certs:ro,/etc/timezone:/etc/timezone`. --- ### BACKEND_DOCKER_LIMIT_MEM_SWAP - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP` - Default: `0` The maximum amount of memory a single pipeline container is allowed to swap to disk, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_MEM - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_MEM` - Default: `0` The maximum amount of memory a single pipeline container can use, configured in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_SHM_SIZE - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE` - Default: `0` The maximum amount of memory of `/dev/shm` allowed in bytes. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_QUOTA - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA` - Default: `0` The number of microseconds per CPU period that the container is limited to before throttled. There is no limit if `0`. --- ### BACKEND_DOCKER_LIMIT_CPU_SHARES - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES` - Default: `0` The relative weight vs. other containers. --- ### BACKEND_DOCKER_LIMIT_CPU_SET - Name: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET` - Default: none Comma-separated list to limit the specific CPUs or cores a pipeline container can use. Example: `WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET=1,2` ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/20-kubernetes.md ================================================ --- toc_max_heading_level: 2 --- # Kubernetes The Kubernetes backend executes steps inside standalone Pods. A temporary PVC is created for the lifetime of the pipeline to transfer files between steps. ## Metadata labels Woodpecker adds some labels to the pods to provide additional context to the workflow. These labels can be used for various purposes, e.g. for simple debugging or as selectors for network policies. The following metadata labels are supported: - `woodpecker-ci.org/forge-id` - `woodpecker-ci.org/repo-forge-id` - `woodpecker-ci.org/repo-id` - `woodpecker-ci.org/repo-name` - `woodpecker-ci.org/repo-full-name` - `woodpecker-ci.org/branch` - `woodpecker-ci.org/org-id` - `woodpecker-ci.org/task-uuid` - `woodpecker-ci.org/step` ## Private registries In addition to [registries specified in the UI](../../../20-usage/41-registries.md), you may provide [registry credentials in Kubernetes Secrets](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/) to pull private container images defined in your pipeline YAML. Place these Secrets in namespace defined by `WOODPECKER_BACKEND_K8S_NAMESPACE` and provide the Secret names to Agents via `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES`. ## Step specific configuration ### Resources The Kubernetes backend also allows for specifying requests and limits on a per-step basic, most commonly for CPU and memory. We recommend to add a `resources` definition to all steps to ensure efficient scheduling. Here is an example definition with an arbitrary `resources` definition below the `backend_options` section: ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: resources: requests: memory: 200Mi cpu: 100m limits: memory: 400Mi cpu: 1000m ``` You can use [Limit Ranges](https://kubernetes.io/docs/concepts/policy/limit-range/) if you want to set the limits by per-namespace basis. ### Runtime class `runtimeClassName` specifies the name of the RuntimeClass which will be used to run this Pod. If no `runtimeClassName` is specified, the default RuntimeHandler will be used. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/containers/runtime-class/) for more information on specifying runtime classes. ### Service account `serviceAccountName` specifies the name of the ServiceAccount which the Pod will mount. This service account must be created externally. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/service-accounts/) for more information on using service accounts. ```yaml steps: - name: 'My kubernetes step' image: alpine commands: - echo "Hello world" backend_options: kubernetes: # Use the service account `default` in the current namespace. # This usually the same as wherever woodpecker is deployed. serviceAccountName: default ``` To give steps access to the Kubernetes API via service account, take a look at [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) ### Node selector `nodeSelector` specifies the labels which are used to select the node on which the step will be executed. Labels defined here will be appended to a list which already contains `"kubernetes.io/arch"`. By default `"kubernetes.io/arch"` is inferred from the agents' platform. One can override it by setting that label in the `nodeSelector` section of the `backend_options`. Without a manual overwrite, builds will be randomly assigned to the runners and inherit their respective architectures. To overwrite this, one needs to set the label in the `nodeSelector` section of the `backend_options`. A practical example for this is when running a matrix-build and delegating specific elements of the matrix to run on a specific architecture. In this case, one must define an arbitrary key in the matrix section of the respective matrix element: ```yaml matrix: include: - NAME: runner1 ARCH: arm64 ``` And then overwrite the `nodeSelector` in the `backend_options` section of the step(s) using the name of the respective env var: ```yaml [...] backend_options: kubernetes: nodeSelector: kubernetes.io/arch: "${ARCH}" ``` You can use [WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR](#backend_k8s_pod_node_selector) if you want to set the node selector per Agent or [PodNodeSelector](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#podnodeselector) admission controller if you want to set the node selector by per-namespace basis. ### Tolerations When you use `nodeSelector` and the node pool is configured with Taints, you need to specify the Tolerations. Tolerations allow the scheduler to schedule Pods with matching taints. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/) for more information on using tolerations. Example pipeline configuration: ```yaml steps: - name: build image: golang commands: - go get - go build - go test backend_options: kubernetes: serviceAccountName: 'my-service-account' resources: requests: memory: 128Mi cpu: 1000m limits: memory: 256Mi nodeSelector: beta.kubernetes.io/instance-type: Standard_D2_v3 tolerations: - key: 'key1' operator: 'Equal' value: 'value1' effect: 'NoSchedule' tolerationSeconds: 3600 affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: topology.kubernetes.io/zone operator: In values: - eu-central-1a - eu-central-1b ``` ### Affinity Kubernetes [affinity and anti-affinity](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity) rules allow you to constrain which nodes your pods can be scheduled on based on node labels, or co-locate/spread pods relative to other pods. You can configure affinity at two levels: 1. **Per-step via `backend_options.kubernetes.affinity`** (shown in example above) - requires agent configuration to allow it 2. **Agent-wide via `WOODPECKER_BACKEND_K8S_POD_AFFINITY`** - applies to all pods unless overridden #### Agent-wide affinity To apply affinity rules to all workflow pods, configure the agent with YAML-formatted affinity: ```yaml WOODPECKER_BACKEND_K8S_POD_AFFINITY: | nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node-role.kubernetes.io/worker operator: In values: - "true" ``` By default, per-step affinity settings are **not allowed** for security reasons. To enable them: ```bash WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP: true ``` :::warning Enabling `WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP` in multi-tenant environments allows pipeline authors to control pod placement, which may have security or resource isolation implications. ::: When per-step affinity is allowed and specified, it **replaces** the agent-wide affinity entirely (not merged). #### Example: agent affinity for co-location This example configures all workflow pods within a workflow to be co-located on the same node, while requiring other workflows run on different nodes. It uses `matchLabelKeys` to dynamically match pods with the same `woodpecker-ci.org/task-uuid`, and `mismatchLabelKeys` to separating pods with different task UUIDs: ```yaml WOODPECKER_BACKEND_K8S_POD_AFFINITY: | podAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: {} matchLabelKeys: - woodpecker-ci.org/task-uuid topologyKey: "kubernetes.io/hostname" podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: {} mismatchLabelKeys: - woodpecker-ci.org/task-uuid topologyKey: "kubernetes.io/hostname" ``` :::note The `matchLabelKeys` and `mismatchLabelKeys` features require Kubernetes v1.29+ (alpha with feature gate `MatchLabelKeysInPodAffinity`) or v1.33+ (beta, enabled by default). These fields allow the Kubernetes API server to dynamically populate label selectors at pod creation time, eliminating the need to hardcode values like `$(WOODPECKER_TASK_UUID)`. ::: #### Example: Node affinity for GPU workloads Ensure a step runs only on GPU-enabled nodes: ```yaml steps: - name: train-model image: tensorflow/tensorflow:latest-gpu backend_options: kubernetes: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: accelerator operator: In values: - nvidia-tesla-v100 ``` ### Volumes To mount volumes a PersistentVolume (PV) and PersistentVolumeClaim (PVC) are needed on the cluster which can be referenced in steps via the `volumes` option. Persistent volumes must be created manually. Use the Kubernetes [Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) documentation as a reference. _If your PVC is not highly available or NFS-based, use the `affinity` settings (documented above) to ensure that your steps are executed on the correct node._ NOTE: If you plan to use this volume in more than one workflow concurrently, make sure you have configured the PVC in `RWX` mode. Keep in mind that this feature must be supported by the used CSI driver: ```yaml accessModes: - ReadWriteMany ``` Assuming a PVC named `woodpecker-cache` exists, it can be referenced as follows in a plugin step: ```yaml steps: - name: "Restore Cache" image: meltwater/drone-cache volumes: - woodpecker-cache:/woodpecker/src/cache settings: mount: - "woodpecker-cache" [...] ``` Or as follows when using a normal image: ```yaml steps: - name: "Edit cache" image: alpine:latest volumes: - woodpecker-cache:/woodpecker/src/cache commands: - echo "Hello World" > /woodpecker/src/cache/output.txt [...] ``` ### Security context Use the following configuration to set the [Security Context](https://kubernetes.io/docs/tasks/configure-pod-container/security-context/) for the Pod/container running a given pipeline step: ```yaml steps: - name: test image: alpine commands: - echo Hello world backend_options: kubernetes: securityContext: runAsUser: 999 runAsGroup: 999 privileged: true [...] ``` Note that the `backend_options.kubernetes.securityContext` object allows you to set both Pod and container level security context options in one object. By default, the properties will be set at the Pod level. Properties that are only supported on the container level will be set there instead. So, the configuration shown above will result in something like the following Pod spec: ```yaml kind: Pod spec: securityContext: runAsUser: 999 runAsGroup: 999 containers: - name: wp-01hcd83q7be5ymh89k5accn3k6-0-step-0 image: alpine securityContext: privileged: true [...] ``` You can also restrict a syscalls of containers with [seccomp](https://kubernetes.io/docs/tutorials/security/seccomp/) profile. ```yaml backend_options: kubernetes: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/audit.json ``` or restrict a container's access to resources by specifying [AppArmor](https://kubernetes.io/docs/tutorials/security/apparmor/) profile ```yaml backend_options: kubernetes: securityContext: apparmorProfile: type: Localhost localhostProfile: k8s-apparmor-example-deny-write ``` or configure a specific `fsGroupChangePolicy` (Kubernetes defaults to 'Always') ```yaml backend_options: kubernetes: securityContext: fsGroupChangePolicy: OnRootMismatch ``` :::note The feature requires Kubernetes v1.30 or above. ::: You can set `allowPrivilegeEscalation` to `false` to prevent a container from gaining more privileges than its parent process. ```yaml backend_options: kubernetes: securityContext: allowPrivilegeEscalation: false ``` You can also drop [Linux capabilities](https://man7.org/linux/man-pages/man7/capabilities.7.html) from a container. Adding capabilities is not allowed. ```yaml backend_options: kubernetes: securityContext: capabilities: drop: - ALL ``` ### Annotations and labels You can specify arbitrary [annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/) and [labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) to be set on the Pod definition for a given workflow step using the following configuration: ```yaml backend_options: kubernetes: annotations: workflow-group: alpha io.kubernetes.cri-o.Devices: /dev/fuse labels: environment: ci app.kubernetes.io/name: builder ``` In order to enable this configuration you need to set the appropriate environment variables to `true` on the woodpecker agent: [WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP](#backend_k8s_pod_annotations_allow_from_step) and/or [WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP](#backend_k8s_pod_labels_allow_from_step). ## Tips and tricks ### CRI-O CRI-O users currently need to configure the workspace for all workflows in order for them to run correctly. Add the following at the beginning of your configuration: ```yaml workspace: base: '/woodpecker' path: '/' ``` See [this issue](https://github.com/woodpecker-ci/woodpecker/issues/2510) for more details. ### `KUBERNETES_SERVICE_HOST` environment variable Like the below env vars used for configuration, this can be set in the environment for configuration of the agent. It configures the address of the Kubernetes API server to connect to. If running the agent within Kubernetes, this will already be set and you don't have to add it manually. ### Headless services For each workflow run a [headless services](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services) is created, and all steps asigned the subdomain that matches the headless service, so any step can reach other steps via DNS by using the step name as hostname. Using the headless services, the step pod is connected to directly, so any port on the other step pods can be reached. This is useful for some use-cases, like test-containers in a docker-in-docker setup, where the step needs to connect to many ports on the docker host service. ```yaml steps: - name: test image: docker:cli # use 'docker:-cli' or similar in production environment: DOCKER_HOST: 'tcp://docker:2376' DOCKER_CERT_PATH: '/woodpecker/dind-certs/client' DOCKER_TLS_VERIFY: '1' commands: - docker run hello-world - name: docker image: docker:dind # use 'docker:-dind' or similar in production detached: true privileged: true environment: DOCKER_TLS_CERTDIR: /woodpecker/dind-certs ``` If ports are defined on a service, then woodpecker will create a normal service for the pod, which use hosts override using the services cluster IP. ## Environment variables These env vars can be set in the `env:` sections of the agent. --- ### BACKEND_K8S_NAMESPACE - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE` - Default: `woodpecker` The namespace to create worker Pods in. --- ### BACKEND_K8S_NAMESPACE_PER_ORGANIZATION - Name: `WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION` - Default: `false` Enables namespace isolation per Woodpecker organization. When enabled, each organization gets its own dedicated Kubernetes namespace for improved security and resource isolation. With this feature enabled, Woodpecker creates separate Kubernetes namespaces for each organization using the format `{WOODPECKER_BACKEND_K8S_NAMESPACE}-{organization-id}`. Namespaces are created automatically when needed, but they are not automatically deleted when organizations are removed from Woodpecker. ### BACKEND_K8S_VOLUME_SIZE - Name: `WOODPECKER_BACKEND_K8S_VOLUME_SIZE` - Default: `10G` The volume size of the pipeline volume. --- ### BACKEND_K8S_STORAGE_CLASS - Name: `WOODPECKER_BACKEND_K8S_STORAGE_CLASS` - Default: none The storage class to use for the pipeline volume. --- ### BACKEND_K8S_STORAGE_RWX - Name: `WOODPECKER_BACKEND_K8S_STORAGE_RWX` - Default: `true` Determines if `RWX` should be used for the pipeline volume's [access mode](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes). If false, `RWO` is used instead. --- ### BACKEND_K8S_POD_LABELS - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS` - Default: none Additional labels to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-label":"test-value"}`. --- ### BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP` - Default: `false` Determines if additional Pod labels can be defined from a step's backend options. --- ### BACKEND_K8S_POD_ANNOTATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS` - Default: none Additional annotations to apply to worker Pods. Must be a YAML object, e.g. `{"example.com/test-annotation":"test-value"}`. --- ### BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP` - Default: `false` Determines if Pod annotations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_TOLERATIONS - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS` - Default: none Additional tolerations to apply to worker Pods. Must be a YAML object, e.g. `[{"effect":"NoSchedule","key":"jobs","operator":"Exists"}]`. --- ### BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP - Name: `WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP` - Default: `true` Determines if Pod tolerations can be defined from a step's backend options. --- ### BACKEND_K8S_POD_NODE_SELECTOR - Name: `WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR` - Default: none Additional node selector to apply to worker pods. Must be a YAML object, e.g. `{"topology.kubernetes.io/region":"eu-central-1"}`. --- ### BACKEND_K8S_SECCTX_NONROOT - Name: `WOODPECKER_BACKEND_K8S_SECCTX_NONROOT` - Default: `false` Determines if containers must be required to run as non-root users. --- ### BACKEND_K8S_PULL_SECRET_NAMES - Name: `WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES` - Default: none Secret names to pull images from private repositories. See, how to [Pull an Image from a Private Registry](https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/). --- ### BACKEND_K8S_PRIORITY_CLASS - Name: `WOODPECKER_BACKEND_K8S_PRIORITY_CLASS` - Default: none, which will use the default priority class configured in Kubernetes Which [Kubernetes PriorityClass](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/priority-class-v1/) to assign to created job pods. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/30-local.md ================================================ --- toc_max_heading_level: 2 --- # Local :::danger The local backend executes pipelines on the local system without any isolation. ::: :::note Currently we do not support [services](../../../20-usage/60-services.md) for this backend. [Read more here](https://github.com/woodpecker-ci/woodpecker/issues/3095). ::: Since the commands run directly in the same context as the agent (same user, same filesystem), a malicious pipeline could be used to access the agent configuration especially the `WOODPECKER_AGENT_SECRET` variable. It is recommended to use this backend only for private setup where the code and pipeline can be trusted. It should not be used in a public instance where anyone can submit code or add new repositories. The agent should not run as a privileged user (root). The local backend will use a random directory in `$TMPDIR` to store the cloned code and execute commands. In order to use this backend, you need to download (or build) the [agent](https://github.com/woodpecker-ci/woodpecker/releases/latest), configure it and run it on the host machine. ## Step specific configuration ### Shell The `image` entrypoint is used to specify the shell, such as `bash` or `fish`, that is used to run the commands. ```yaml title=".woodpecker.yaml" steps: - name: build image: bash commands: [...] ``` ### Plugins ```yaml steps: - name: build image: /usr/bin/tree ``` If no commands are provided, plugins are treated in the usual manner. In the context of the local backend, plugins are simply executable binaries, which can be located using their name if they are listed in `$PATH`, or through an absolute path. ## Environment variables ### BACKEND_LOCAL_TEMP_DIR - Name: `WOODPECKER_BACKEND_LOCAL_TEMP_DIR` - Default: default temp directory Directory to create folders for workflows. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/50-custom.md ================================================ # Custom If none of our backends fit your use case, you can write your own. To do this, implement the interface `“go.woodpecker-ci.org/woodpecker/woodpecker/v3/pipeline/backend/types”.backend` and create a custom agent that uses your backend: ```go package main import ( "go.woodpecker-ci.org/woodpecker/v3/cmd/agent/core" backendTypes "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func main() { core.RunAgent([]backendTypes.Backend{ yourBackend, }) } ``` ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/11-backends/_category_.yaml ================================================ label: 'Backends' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/11-overview.md ================================================ # Forges ## Supported features | Feature | [GitHub](20-github.md) | [Gitea](30-gitea.md) | [Forgejo](35-forgejo.md) | [Gitlab](40-gitlab.md) | [Bitbucket](50-bitbucket.md) | [Bitbucket Datacenter](60-bitbucket_datacenter.md) | | ---------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------- | ------------------------ | ---------------------- | ---------------------------- | -------------------------------------------------- | | Event: Push | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Tag | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Pull-Request | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Event: Release | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | Event: Deploy¹ | :white_check_mark: | :x: | :x: | :x: | :x: | :x: | | [Event: Pull-Request-Metadata](../../../20-usage/50-environment.md#pull_request_metadata-specific-event-reason-values) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :x: | :x: | | [Multiple workflows](../../../20-usage/25-workflows.md) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [when.path filter](../../../20-usage/20-workflow-syntax.md#path) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | ¹ The deployment event can be triggered for all forges from Woodpecker directly. However, only GitHub can trigger them using webhooks. In addition to this, Woodpecker supports [addon forges](../100-addons.md) if the forge you are using does not meet the [Woodpecker requirements](../../../92-development/02-core-ideas.md#forges) or your setup is too specific to be included in the Woodpecker core. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/20-github.md ================================================ --- toc_max_heading_level: 2 --- # GitHub Woodpecker comes with built-in support for GitHub and GitHub Enterprise. To use Woodpecker with GitHub the following environment variables should be set for the server component: ```ini WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT=YOUR_GITHUB_CLIENT_ID WOODPECKER_GITHUB_SECRET=YOUR_GITHUB_CLIENT_SECRET ``` You will get these values from GitHub when you register your OAuth application. To do so, go to Settings -> Developer Settings -> GitHub Apps -> New Oauth2 App. :::warning Do not use a "GitHub App" instead of an Oauth2 app as the former will not work correctly with Woodpecker right now (because user access tokens are not being refreshed automatically) ::: ## App Settings - Name: An arbitrary name for your App - Homepage URL: The URL of your Woodpecker instance - Callback URL: `https:///authorize` - (optional) Upload the Woodpecker Logo: ## Client Secret Creation After your App has been created, you can generate a client secret. Use this one for the `WOODPECKER_GITHUB_SECRET` environment variable. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITHUB - Name: `WOODPECKER_GITHUB` - Default: `false` Enables the GitHub driver. --- ### GITHUB_URL - Name: `WOODPECKER_GITHUB_URL` - Default: `https://github.com` Configures the GitHub server address. --- ### GITHUB_CLIENT - Name: `WOODPECKER_GITHUB_CLIENT` - Default: none Configures the GitHub OAuth client id to authorize access. --- ### GITHUB_CLIENT_FILE - Name: `WOODPECKER_GITHUB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_CLIENT` from the specified filepath. --- ### GITHUB_SECRET - Name: `WOODPECKER_GITHUB_SECRET` - Default: none Configures the GitHub OAuth client secret. This is used to authorize access. --- ### GITHUB_SECRET_FILE - Name: `WOODPECKER_GITHUB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITHUB_SECRET` from the specified filepath. --- ### GITHUB_MERGE_REF - Name: `WOODPECKER_GITHUB_MERGE_REF` - Default: `true` --- ### GITHUB_SKIP_VERIFY - Name: `WOODPECKER_GITHUB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### GITHUB_PUBLIC_ONLY - Name: `WOODPECKER_GITHUB_PUBLIC_ONLY` - Default: `false` Configures the GitHub OAuth client to only obtain a token that can manage public repositories. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/30-gitea.md ================================================ --- toc_max_heading_level: 2 --- # Gitea Woodpecker comes with built-in support for Gitea. To enable Gitea you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITEA=true WOODPECKER_GITEA_URL=YOUR_GITEA_URL WOODPECKER_GITEA_CLIENT=YOUR_GITEA_CLIENT WOODPECKER_GITEA_SECRET=YOUR_GITEA_CLIENT_SECRET ``` ## Gitea on the same host with containers If you have Gitea also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Gitea reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Gitea is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `gitea`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=gitea ``` ## Registration ### User OAuth Application Register your application with Gitea to create your client id and secret. You can find the OAuth applications settings of Gitea at `https://gitea./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. ### System-wide OAuth Application If you are the administrator of both Gitea and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Gitea site administrator level and are visible to all users. To create a system-wide OAuth application in Gitea: 1. Navigate to the site administration settings at `https://gitea./admin/settings/applications` 2. Create a new OAuth2 application under the "OAuth2 Applications" section 3. Configure the application with the same settings as above (callback URL, etc.) 4. Use the generated client id and secret for Woodpecker configuration System-wide applications are particularly useful for: - Shared CI/CD environments where multiple users need Woodpecker access - Organizations that want centralized control over OAuth applications - Preventing user-level application quotas from affecting CI/CD operations ### Local Connections If you run the Woodpecker CI server on the same host as the Gitea instance, you might also need to allow local connections in Gitea, since version `v1.16`. Otherwise webhooks will fail. Add the following lines to your Gitea configuration (usually at `/etc/gitea/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://docs.gitea.io/en-us/config-cheat-sheet/#webhook-webhook). ![gitea oauth setup](gitea_oauth.gif) :::warning Make sure your Gitea configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://docs.gitea.com/administration/config-cheat-sheet#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITEA - Name: `WOODPECKER_GITEA` - Default: `false` Enables the Gitea driver. --- ### GITEA_URL - Name: `WOODPECKER_GITEA_URL` - Default: `https://try.gitea.io` Configures the Gitea server address. --- ### GITEA_CLIENT - Name: `WOODPECKER_GITEA_CLIENT` - Default: none Configures the Gitea OAuth client id. This is used to authorize access. --- ### GITEA_CLIENT_FILE - Name: `WOODPECKER_GITEA_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITEA_CLIENT` from the specified filepath --- ### GITEA_SECRET - Name: `WOODPECKER_GITEA_SECRET` - Default: none Configures the Gitea OAuth client secret. This is used to authorize access. --- ### GITEA_SECRET_FILE - Name: `WOODPECKER_GITEA_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITEA_SECRET` from the specified filepath --- ### GITEA_SKIP_VERIFY - Name: `WOODPECKER_GITEA_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/35-forgejo.md ================================================ --- toc_max_heading_level: 2 --- # Forgejo Woodpecker comes with built-in support for Forgejo. To enable Forgejo you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_FORGEJO=true WOODPECKER_FORGEJO_URL=YOUR_FORGEJO_URL WOODPECKER_FORGEJO_CLIENT=YOUR_FORGEJO_CLIENT WOODPECKER_FORGEJO_SECRET=YOUR_FORGEJO_CLIENT_SECRET ``` ## Forgejo on the same host with containers If you have Forgejo also running on the same host within a container, make sure the agent does have access to it. The agent tries to clone using the URL which Forgejo reports through its API. For simplified connectivity, you should add the Woodpecker agent to the same docker network as Forgejo is in. Otherwise, the communication should go via the `docker0` gateway (usually 172.17.0.1). To configure the Docker network if the network's name is `forgejo`, configure it like this: ```diff title="docker-compose.yaml" services: [...] woodpecker-agent: [...] environment: - [...] + - WOODPECKER_BACKEND_DOCKER_NETWORK=forgejo ``` ## Registration ### User OAuth Application Register your application with Forgejo to create your client id and secret. You can find the OAuth applications settings of Forgejo at `https://forgejo./user/settings/`. It is very important that authorization callback URL matches your http(s) scheme and hostname exactly with `https:///authorize` as the path. ### System-wide OAuth Application If you are the administrator of both Forgejo and Woodpecker, you may prefer to use a system-wide OAuth application instead of a user-level application. System-wide applications are managed at the Forgejo site administrator level and are visible to all users. To create a system-wide OAuth application in Forgejo: 1. Navigate to the site administration settings at `https://forgejo./admin/settings/applications` 2. Create a new OAuth2 application under the "OAuth2 Applications" section 3. Configure the application with the same settings as above (callback URL, etc.) 4. Use the generated client id and secret for Woodpecker configuration System-wide applications are particularly useful for: - Shared CI/CD environments where multiple users need Woodpecker access - Organizations that want centralized control over OAuth applications - Preventing user-level application quotas from affecting CI/CD operations ### Local Connections If you run the Woodpecker CI server on the same host as the Forgejo instance, you might also need to allow local connections in Forgejo. Otherwise webhooks will fail. Add the following lines to your Forgejo configuration (usually at `/etc/forgejo/conf/app.ini`). ```ini [webhook] ALLOWED_HOST_LIST=external,loopback ``` For reference see [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook). ![forgejo oauth setup](gitea_oauth.gif) :::warning Make sure your Forgejo configuration allows requesting the API with a fixed page length of 50. The default value for the maximum page size is 50, but if you set a value lower than 50, some Woodpecker features will not work properly. Also see the [Configuration Cheat Sheet](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#api-api). ::: ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### FORGEJO - Name: `WOODPECKER_FORGEJO` - Default: `false` Enables the Forgejo driver. --- ### FORGEJO_URL - Name: `WOODPECKER_FORGEJO_URL` - Default: `https://next.forgejo.org` Configures the Forgejo server address. --- ### FORGEJO_CLIENT - Name: `WOODPECKER_FORGEJO_CLIENT` - Default: none Configures the Forgejo OAuth client id. This is used to authorize access. --- ### FORGEJO_CLIENT_FILE - Name: `WOODPECKER_FORGEJO_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_CLIENT` from the specified filepath --- ### FORGEJO_SECRET - Name: `WOODPECKER_FORGEJO_SECRET` - Default: none Configures the Forgejo OAuth client secret. This is used to authorize access. --- ### FORGEJO_SECRET_FILE - Name: `WOODPECKER_FORGEJO_SECRET_FILE` - Default: none Read the value for `WOODPECKER_FORGEJO_SECRET` from the specified filepath --- ### FORGEJO_SKIP_VERIFY - Name: `WOODPECKER_FORGEJO_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/40-gitlab.md ================================================ --- toc_max_heading_level: 2 --- # GitLab Woodpecker comes with built-in support for the GitLab version 12.4 and higher. To enable GitLab you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_GITLAB=true WOODPECKER_GITLAB_URL=http://gitlab.mycompany.com WOODPECKER_GITLAB_CLIENT=95c0282573633eb25e82 WOODPECKER_GITLAB_SECRET=30f5064039e6b359e075 ``` ## Registration You must register your application with GitLab in order to generate a Client and Secret. Navigate to your account settings and choose Applications from the menu, and click New Application. Please use `http://woodpecker.mycompany.com/authorize` as the Authorization callback URL. Grant `api` scope to the application. If you run the Woodpecker CI server on a private IP (RFC1918) or use a non standard TLD (e.g. `.local`, `.intern`) with your GitLab instance, you might also need to allow local connections in GitLab, otherwise API requests will fail. In GitLab, navigate to the Admin dashboard, then go to `Settings > Network > Outbound requests` and enable `Allow requests to the local network from web hooks and services`. ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### GITLAB - Name: `WOODPECKER_GITLAB` - Default: `false` Enables the GitLab driver. --- ### GITLAB_URL - Name: `WOODPECKER_GITLAB_URL` - Default: `https://gitlab.com` Configures the GitLab server address. --- ### GITLAB_CLIENT - Name: `WOODPECKER_GITLAB_CLIENT` - Default: none Configures the GitLab OAuth client id. This is used to authorize access. --- ### GITLAB_CLIENT_FILE - Name: `WOODPECKER_GITLAB_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_CLIENT` from the specified filepath --- ### GITLAB_SECRET - Name: `WOODPECKER_GITLAB_SECRET` - Default: none Configures the GitLab OAuth client secret. This is used to authorize access. --- ### GITLAB_SECRET_FILE - Name: `WOODPECKER_GITLAB_SECRET_FILE` - Default: none Read the value for `WOODPECKER_GITLAB_SECRET` from the specified filepath --- ### GITLAB_SKIP_VERIFY - Name: `WOODPECKER_GITLAB_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/50-bitbucket.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Woodpecker comes with built-in support for Bitbucket Cloud. To enable Bitbucket Cloud you should configure the Woodpecker container using the following environment variables: ```ini WOODPECKER_BITBUCKET=true WOODPECKER_BITBUCKET_CLIENT=... # called "Key" in Bitbucket WOODPECKER_BITBUCKET_SECRET=... ``` ## Registration You must register an OAuth application at Bitbucket in order to get a key and secret combination for Woodpecker. Navigate to your workspace settings and choose `OAuth consumers` from the menu, and finally click `Add Consumer` (the url should be like: `https://bitbucket.org/[your-project-name]/workspace/settings/api`). Please set a name and set the `Callback URL` like this: ```uri https:///authorize ``` ![bitbucket oauth setup](bitbucket_oauth.png) Please also be sure to check the following permissions: - Account: Email, Read - Workspace membership: Read - Projects: Read - Repositories: Read - Pull requests: Read - Webhooks: Read and Write ![bitbucket permissions](bitbucket_permissions.png) ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET - Name: `WOODPECKER_BITBUCKET` - Default: `false` Enables the Bitbucket driver. --- ### BITBUCKET_CLIENT - Name: `WOODPECKER_BITBUCKET_CLIENT` - Default: none Configures the Bitbucket OAuth client key. This is used to authorize access. --- ### BITBUCKET_CLIENT_FILE - Name: `WOODPECKER_BITBUCKET_CLIENT_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_CLIENT` from the specified filepath --- ### BITBUCKET_SECRET - Name: `WOODPECKER_BITBUCKET_SECRET` - Default: none Configures the Bitbucket OAuth client secret. This is used to authorize access. --- ### BITBUCKET_SECRET_FILE - Name: `WOODPECKER_BITBUCKET_SECRET_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_SECRET` from the specified filepath ## Known Issues Bitbucket build keys are limited to 40 characters: [issue #5176](https://github.com/woodpecker-ci/woodpecker/issues/5176). If a job exceeds this limit, you can adjust the key by modifying the `WOODPECKER_STATUS_CONTEXT` or `WOODPECKER_STATUS_CONTEXT_FORMAT` variables. See the [environment variables documentation](../10-server.md#environment-variables) for more details. ## Missing Features Path filters for pull requests are not supported. We are interested in patches to include this functionality. If you are interested in contributing to Woodpecker and submitting a patch please **contact us** via [Discord](https://discord.gg/fcMQqSMXJy) or [Matrix](https://matrix.to/#/#WoodpeckerCI-Develop:obermui.de). ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/60-bitbucket_datacenter.md ================================================ --- toc_max_heading_level: 2 --- # Bitbucket Datacenter / Server :::warning Woodpecker comes with experimental support for Bitbucket Datacenter / Server, formerly known as Atlassian Stash. ::: To enable Bitbucket Server you should configure the Woodpecker container using the following environment variables: ```diff title="docker-compose.yaml" services: woodpecker-server: [...] environment: - [...] + - WOODPECKER_BITBUCKET_DC=true + - WOODPECKER_BITBUCKET_DC_GIT_USERNAME=foo + - WOODPECKER_BITBUCKET_DC_GIT_PASSWORD=bar + - WOODPECKER_BITBUCKET_DC_CLIENT_ID=xxx + - WOODPECKER_BITBUCKET_DC_CLIENT_SECRET=yyy + - WOODPECKER_BITBUCKET_DC_URL=http://stash.mycompany.com + - WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN=true woodpecker-agent: [...] ``` ## Service Account Woodpecker uses `git+https` to clone repositories, however, Bitbucket Server does not currently support cloning repositories with an OAuth token. To work around this limitation, you must create a service account and provide the username and password to Woodpecker. This service account will be used to authenticate and clone private repositories. ## Registration Woodpecker must be registered with Bitbucket Datacenter / Server. In the administration section of Bitbucket choose "Application Links" and then "Create link". Woodpecker should be listed as "External Application" and the direction should be set to "Incoming". Note the client id and client secret of the registration to be used in the configuration of Woodpecker. See also [Configure an incoming link](https://confluence.atlassian.com/bitbucketserver/configure-an-incoming-link-1108483657.html). ## Configuration This is a full list of configuration options. Please note that many of these options use default configuration values that should work for the majority of installations. --- ### BITBUCKET_DC - Name: `WOODPECKER_BITBUCKET_DC` - Default: `false` Enables the Bitbucket Server driver. --- ### BITBUCKET_DC_URL - Name: `WOODPECKER_BITBUCKET_DC_URL` - Default: none Configures the Bitbucket Server address. --- ### BITBUCKET_DC_CLIENT_ID - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_ID` - Default: none Configures your Bitbucket Server OAUth 2.0 client id. --- ### BITBUCKET_DC_CLIENT_SECRET - Name: `WOODPECKER_BITBUCKET_DC_CLIENT_SECRET` - Default: none Configures your Bitbucket Server OAUth 2.0 client secret. --- ### BITBUCKET_DC_GIT_USERNAME - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` - Default: none This username is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_USERNAME_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_USERNAME_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_USERNAME` from the specified filepath --- ### BITBUCKET_DC_GIT_PASSWORD - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` - Default: none The password is used to authenticate and clone all private repositories. --- ### BITBUCKET_DC_GIT_PASSWORD_FILE - Name: `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD_FILE` - Default: none Read the value for `WOODPECKER_BITBUCKET_DC_GIT_PASSWORD` from the specified filepath --- ### BITBUCKET_DC_SKIP_VERIFY - Name: `WOODPECKER_BITBUCKET_DC_SKIP_VERIFY` - Default: `false` Configure if SSL verification should be skipped. --- ### BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN - Name: `WOODPECKER_BITBUCKET_DC_ENABLE_OAUTH2_SCOPE_PROJECT_ADMIN` - Default: `false` When enabled, the Bitbucket Application Link for Woodpecker should include the `PROJECT_ADMIN` scope. Enabling this feature flag will allow the users of Bitbucket Datacenter to use organization secrets and properly list repositories within the organization. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/12-forges/_category_.yaml ================================================ label: 'Forges' collapsible: true collapsed: true link: type: 'doc' id: 'overview' ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/30-agent.md ================================================ --- toc_max_heading_level: 3 --- # Agent Agents are configured by the command line or environment variables. At the minimum you need the following information: ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" ``` The following are automatically set and can be overridden: - `WOODPECKER_HOSTNAME` if not set, becomes the OS' hostname - `WOODPECKER_MAX_WORKFLOWS` if not set, defaults to 1 ## Workflows per agent By default, the maximum workflows that are executed in parallel on an agent is 1. If required, you can add `WOODPECKER_MAX_WORKFLOWS` to increase your parallel processing for an agent. ```ini WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET="your-shared-secret-goes-here" WOODPECKER_MAX_WORKFLOWS=4 ``` ## Agent registration When the agent starts it connects to the server using the token from `WOODPECKER_AGENT_SECRET`. The server identifies the agent and registers the agent in its database if it wasn't connected before. There are two types of tokens to connect an agent to the server: ### Using system token A _system token_ is a token that is used system-wide, e.g. when you set the same token in `WOODPECKER_AGENT_SECRET` on both the server and the agents. In that case registration process would be as following: 1. The first time the agent communicates with the server, it is using the system token 1. The server registers the agent in its database if not done before and generates a unique ID which is then sent back to the agent 1. The agent stores the received ID in a file (configured by `WOODPECKER_AGENT_CONFIG_FILE`) 1. At the following startups, the agent uses the system token **and** its received ID to identify itself to the server ### Using agent token An _agent token_ is a token that is used by only one particular agent. This unique token is applied to the agent by `WOODPECKER_AGENT_SECRET`. To get an _agent token_ you have to register the agent manually in the server using the UI: 1. The administrator registers a new agent manually at `Settings -> Agents -> Add agent` ![Agent creation](./new-agent-registration.png) ![Agent created](./new-agent-created.png) 1. The generated token from the previous step has to be provided to the agent using `WOODPECKER_AGENT_SECRET` 1. The agent will connect to the server using the provided token and will update its status in the UI: ![Agent connected](./new-agent-connected.png) ## Environment variables ### SERVER - Name: `WOODPECKER_SERVER` - Default: `localhost:9000` Configures gRPC address of the server. --- ### USERNAME - Name: `WOODPECKER_USERNAME` - Default: `x-oauth-basic` The gRPC username. --- ### AGENT_SECRET - Name: `WOODPECKER_AGENT_SECRET` - Default: none A shared secret used by server and agents to authenticate communication. A secret can be generated by `openssl rand -hex 32`. --- ### AGENT_SECRET_FILE - Name: `WOODPECKER_AGENT_SECRET_FILE` - Default: none Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath, e.g. `/etc/woodpecker/agent-secret.conf` --- ### LOG_LEVEL - Name: `WOODPECKER_LOG_LEVEL` - Default: `info` Configures the logging level. Possible values are `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`, `disabled` and empty. --- ### DEBUG_PRETTY - Name: `WOODPECKER_DEBUG_PRETTY` - Default: `false` Enable pretty-printed debug output. --- ### DEBUG_NOCOLOR - Name: `WOODPECKER_DEBUG_NOCOLOR` - Default: `true` Disable colored debug output. --- ### HOSTNAME - Name: `WOODPECKER_HOSTNAME` - Default: none Configures the agent hostname. --- ### AGENT_CONFIG_FILE - Name: `WOODPECKER_AGENT_CONFIG_FILE` - Default: `/etc/woodpecker/agent.conf` Configures the path of the agent config file. --- ### MAX_WORKFLOWS - Name: `WOODPECKER_MAX_WORKFLOWS` - Default: `1` Configures the number of parallel workflows. --- ### AGENT_SINGLE_WORKFLOW - Name: `WOODPECKER_AGENT_SINGLE_WORKFLOW` - Default: `false` Configures the agent to exit (shutdown) after executing one workflow. When configured, `WOODPECKER_MAX_WORKFLOWS` is forced to 1. This one-shot mode is useful in ephemeral environments that are provisioned on demand by external automation — for example, when an autoscaler spins up a dedicated machine. In these setups, the agent starts, executes exactly one workflow, and exits, allowing the environment to be cleanly torn down afterward. --- ### AGENT_LABELS - Name: `WOODPECKER_AGENT_LABELS` - Default: none Configures custom labels for the agent, to let workflows filter by it. Use a list of key-value pairs like `key=value,second-key=*`. `*` can be used as a wildcard. If you use `!` as key prefix it is mandatory for the workflow to have that label set (without !) set and matched. By default, agents provide four additional labels `platform=os/arch`, `hostname=my-agent`, `backend=my-backend` and `repo=*` which can be overwritten if needed. To learn how labels work, check out the [pipeline syntax page](../../20-usage/20-workflow-syntax.md#labels). --- ### HEALTHCHECK - Name: `WOODPECKER_HEALTHCHECK` - Default: `true` Enable healthcheck endpoint. --- ### HEALTHCHECK_ADDR - Name: `WOODPECKER_HEALTHCHECK_ADDR` - Default: `:3000` Configures healthcheck endpoint address. --- ### KEEPALIVE_TIME - Name: `WOODPECKER_KEEPALIVE_TIME` - Default: none After a duration of this time of no activity, the agent pings the server to check if the transport is still alive. --- ### KEEPALIVE_TIMEOUT - Name: `WOODPECKER_KEEPALIVE_TIMEOUT` - Default: `20s` After pinging for a keepalive check, the agent waits for a duration of this time before closing the connection if no activity. --- ### GRPC_SECURE - Name: `WOODPECKER_GRPC_SECURE` - Default: `false` Configures if the connection to `WOODPECKER_SERVER` should be made using a secure transport. --- ### GRPC_VERIFY - Name: `WOODPECKER_GRPC_VERIFY` - Default: `true` Configures if the gRPC server certificate should be verified, only valid when `WOODPECKER_GRPC_SECURE` is `true`. --- ## RETRY_TIMEOUT - Name: `WOODPECKER_RETRY_TIMEOUT` - Default: `2m` Set how long the agent keeps retrying to reconnect to the server after the gRPC connection is lost before giving up. :::warning If set to 0 we retry forever. ::: --- ### BACKEND - Name: `WOODPECKER_BACKEND` - Default: `auto-detect` Configures the backend engine to run pipelines on. Possible values are `auto-detect`, `docker`, `local` or `kubernetes`. ### BACKEND_DOCKER\_\* See [Docker backend configuration](./11-backends/10-docker.md#environment-variables) --- ### BACKEND_K8S\_\* See [Kubernetes backend configuration](./11-backends/20-kubernetes.md#environment-variables) --- ### BACKEND_LOCAL\_\* See [Local backend configuration](./11-backends/30-local.md#environment-variables) ### Advanced Settings :::warning Only change these If you know what you do. ::: #### CONNECT_RETRY_COUNT - Name: `WOODPECKER_CONNECT_RETRY_COUNT` - Default: `5` Configures number of times agent retries to connect to the server. #### CONNECT_RETRY_DELAY - Name: `WOODPECKER_CONNECT_RETRY_DELAY` - Default: `2s` Configures delay between agent connection retries to the server. ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/40-autoscaler.md ================================================ # Autoscaler If your would like dynamically scale your agents with the load, you can use [our autoscaler](https://github.com/woodpecker-ci/autoscaler). Please note that the autoscaler is not feature-complete yet. You can follow the progress [here](https://github.com/woodpecker-ci/autoscaler#roadmap). ## Setup ### docker compose If you are using docker compose you can add the following to your `docker-compose.yaml` file: ```yaml services: woodpecker-server: image: woodpeckerci/woodpecker-server:next [...] woodpecker-autoscaler: image: woodpeckerci/autoscaler:next restart: always depends_on: - woodpecker-server environment: - WOODPECKER_SERVER=https://your-woodpecker-server.tld # the url of your woodpecker server / could also be a public url - WOODPECKER_TOKEN=${WOODPECKER_TOKEN} # the api token you can get from the UI https://your-woodpecker-server.tld/user - WOODPECKER_MIN_AGENTS=0 - WOODPECKER_MAX_AGENTS=3 - WOODPECKER_WORKFLOWS_PER_AGENT=2 # the number of workflows each agent can run at the same time - WOODPECKER_GRPC_ADDR=grpc.your-woodpecker-server.tld # the grpc address of your woodpecker server, publicly accessible from the agents. See https://woodpecker-ci.org/docs/administration/configuration/server#caddy for an example of how to expose it. Do not include "https://" in the value. - WOODPECKER_GRPC_SECURE=true - WOODPECKER_AGENT_ENV= # optional environment variables to pass to the agents - WOODPECKER_PROVIDER=hetznercloud # set the provider, you can find all the available ones down below - WOODPECKER_HETZNERCLOUD_API_TOKEN=${WOODPECKER_HETZNERCLOUD_API_TOKEN} # your api token for the Hetzner cloud ``` ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/10-configuration/_category_.yaml ================================================ label: 'Configuration' collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.14/30-administration/_category_.yaml ================================================ label: 'Administration' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.14/40-cli.md ================================================ # CLI # NAME woodpecker-cli - command line utility # SYNOPSIS woodpecker-cli ``` [--config|-c]=[value] [--disable-update-check] [--log-file]=[value] [--log-level]=[value] [--nocolor] [--pretty] [--server|-s]=[value] [--skip-verify] [--socks-proxy-off] [--socks-proxy]=[value] [--token|-t]=[value] ``` # DESCRIPTION Woodpecker command line utility **Usage**: ``` woodpecker-cli [GLOBAL OPTIONS] [command [COMMAND OPTIONS]] [ARGUMENTS...] ``` # GLOBAL OPTIONS **--config, -c**="": path to config file **--disable-update-check**: disable update check (default: false) **--log-file**="": Output destination for logs. 'stdout' and 'stderr' can be used as special keywords. (default: stderr) **--log-level**="": set logging level (default: info) **--nocolor**: disable colored debug output, only has effect if pretty output is set too (default: false) **--pretty**: enable pretty-printed debug output (default: true) **--server, -s**="": server address **--skip-verify**: skip ssl verification (default: false) **--socks-proxy**="": socks proxy address **--socks-proxy-off**: socks proxy ignored (default: false) **--token, -t**="": server auth token # COMMANDS ## admin manage server settings ### log-level retrieve log level from server, or set it with [level] ### org manage organizations #### ls list organizations **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nOrganization ID: {{ .ID }}\n) ### registry manage global registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username ### secret manage global secrets #### add add a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--value**="": secret value #### rm remove a secret **--name**="": secret name #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name #### update update a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--value**="": secret value ### user manage users #### add add a user #### ls list all users **--format**="": format output (default: {{ .Login }}) #### rm remove a user #### show show user information **--format**="": format output (default: User: {{ .Login }}\nEmail: {{ .Email }}) ## context, ctx manage contexts ### list, ls list all contexts **--output**="": output format (default: table) **--output-no-headers**: do not print headers in output (default: false) **--output-no-headers**: don't print headers (default: false) ### use set the current context ### delete, rm delete a context ### rename rename a context ## exec execute a local pipeline **--backend-docker-api-version**="": the version of the API to reach, leave empty for latest. **--backend-docker-cert**="": path to load the TLS certificates for connecting to docker server **--backend-docker-host**="": path to docker socket or url to the docker server **--backend-docker-ipv6**: backend docker enable IPV6 (default: false) **--backend-docker-limit-cpu-quota**="": impose a cpu quota (default: 0) **--backend-docker-limit-cpu-set**="": set the cpus allowed to execute containers **--backend-docker-limit-cpu-shares**="": change the cpu shares (default: 0) **--backend-docker-limit-mem**="": maximum memory allowed in bytes (default: 0) **--backend-docker-limit-mem-swap**="": maximum memory used for swap in bytes (default: 0) **--backend-docker-limit-shm-size**="": docker /dev/shm allowed in bytes (default: 0) **--backend-docker-network**="": backend docker network **--backend-docker-stop-timeout**="": seconds Woodpecker waits for a container to stop gracefully before forcefully killing it (default: 20) **--backend-docker-tls-verify**: enable or disable TLS verification for connecting to docker server (default: true) **--backend-docker-volumes**="": backend docker volumes (comma separated) **--backend-engine**="": backend engine to run pipelines on (default: auto-detect) **--backend-http-proxy**="": if set, pass the environment variable down as "HTTP_PROXY" to steps **--backend-https-proxy**="": if set, pass the environment variable down as "HTTPS_PROXY" to steps **--backend-k8s-allow-native-secrets**: whether to allow existing Kubernetes secrets to be referenced from steps (default: false) **--backend-k8s-namespace**="": backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name. (default: woodpecker) **--backend-k8s-namespace-per-org**: Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization. (default: false) **--backend-k8s-pod-affinity**="": backend k8s Agent-wide worker pod affinity, in YAML format **--backend-k8s-pod-affinity-allow-from-step**: whether to allow using affinity from step's backend options (default: false) **--backend-k8s-pod-annotations**="": backend k8s additional Agent-wide worker pod annotations **--backend-k8s-pod-annotations-allow-from-step**: whether to allow using annotations from step's backend options (default: false) **--backend-k8s-pod-image-pull-secret-names**="": backend k8s pull secret names for private registries **--backend-k8s-pod-labels**="": backend k8s additional Agent-wide worker pod labels **--backend-k8s-pod-labels-allow-from-step**: whether to allow using labels from step's backend options (default: false) **--backend-k8s-pod-node-selector**="": backend k8s Agent-wide worker pod node selector **--backend-k8s-pod-tolerations**="": backend k8s Agent-wide worker pod tolerations **--backend-k8s-pod-tolerations-allow-from-step**: whether to allow using tolerations from step's backend options (default: true) **--backend-k8s-priority-class**="": which kubernetes priority class to assign to created job pods **--backend-k8s-secctx-nonroot**: `run as non root` Kubernetes security context option (default: false) **--backend-k8s-stop-timeout**="": seconds Woodpecker waits for pods to stop gracefully before forcefully killing them (default: 20) **--backend-k8s-storage-class**="": backend k8s storage class **--backend-k8s-storage-rwx**: backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true) (default: true) **--backend-k8s-volume-size**="": backend k8s volume size (default 10G) (default: 10G) **--backend-local-isolated-home**: set HOME, USERPROFILE and other variables to an isolated directory, if false we ignore netrc (default: true) **--backend-local-temp-dir**="": set a different temp dir to clone workflows into (default: system temporary directory) **--backend-no-proxy**="": if set, pass the environment variable down as "NO_PROXY" to steps **--commit-author-avatar**="": Set the metadata environment variable "CI_COMMIT_AUTHOR_AVATAR". **--commit-author-email**="": Set the metadata environment variable "CI_COMMIT_AUTHOR_EMAIL". **--commit-author-name**="": Set the metadata environment variable "CI_COMMIT_AUTHOR". **--commit-branch**="": Set the metadata environment variable "CI_COMMIT_BRANCH". (default: main) **--commit-message**="": Set the metadata environment variable "CI_COMMIT_MESSAGE". **--commit-pull-labels**="": Set the metadata environment variable "CI_COMMIT_PULL_REQUEST_LABELS". **--commit-pull-milestone**="": Set the metadata environment variable "CI_COMMIT_PULL_REQUEST_MILESTONE". **--commit-ref**="": Set the metadata environment variable "CI_COMMIT_REF". **--commit-refspec**="": Set the metadata environment variable "CI_COMMIT_REFSPEC". **--commit-release-is-pre**: Set the metadata environment variable "CI_COMMIT_PRERELEASE". (default: false) **--commit-sha**="": Set the metadata environment variable "CI_COMMIT_SHA". **--env**="": Set the metadata environment variable "CI_ENV". **--forge-type**="": Set the metadata environment variable "CI_FORGE_TYPE". **--forge-url**="": Set the metadata environment variable "CI_FORGE_URL". **--local**: run from local directory (default: true) **--metadata-file**="": path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags **--netrc-machine**="": **--netrc-password**="": **--netrc-username**="": **--network**="": external networks **--pipeline-changed-files**="": Set the metadata environment variable "CI_PIPELINE_FILES", either json formatted list of strings, or comma separated string list. **--pipeline-created**="": Set the metadata environment variable "CI_PIPELINE_CREATED". (default: 0) **--pipeline-deploy-task**="": Set the metadata environment variable "CI_PIPELINE_DEPLOY_TASK". **--pipeline-deploy-to**="": Set the metadata environment variable "CI_PIPELINE_DEPLOY_TARGET". **--pipeline-event**="": Set the metadata environment variable "CI_PIPELINE_EVENT". (default: manual) **--pipeline-number**="": Set the metadata environment variable "CI_PIPELINE_NUMBER". (default: 0) **--pipeline-parent**="": Set the metadata environment variable "CI_PIPELINE_PARENT". (default: 0) **--pipeline-started**="": Set the metadata environment variable "CI_PIPELINE_STARTED". (default: 0) **--pipeline-url**="": Set the metadata environment variable "CI_PIPELINE_FORGE_URL". **--plugins-privileged**="": Allow plugins to run in privileged mode, if environment variable is defined but empty there will be none **--prev-commit-author-avatar**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR_AVATAR". **--prev-commit-author-email**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR_EMAIL". **--prev-commit-author-name**="": Set the metadata environment variable "CI_PREV_COMMIT_AUTHOR". **--prev-commit-branch**="": Set the metadata environment variable "CI_PREV_COMMIT_BRANCH". **--prev-commit-message**="": Set the metadata environment variable "CI_PREV_COMMIT_MESSAGE". **--prev-commit-ref**="": Set the metadata environment variable "CI_PREV_COMMIT_REF". **--prev-commit-refspec**="": Set the metadata environment variable "CI_PREV_COMMIT_REFSPEC". **--prev-commit-sha**="": Set the metadata environment variable "CI_PREV_COMMIT_SHA". **--prev-pipeline-created**="": Set the metadata environment variable "CI_PREV_PIPELINE_CREATED". (default: 0) **--prev-pipeline-deploy-task**="": Set the metadata environment variable "CI_PREV_PIPELINE_DEPLOY_TASK". **--prev-pipeline-deploy-to**="": Set the metadata environment variable "CI_PREV_PIPELINE_DEPLOY_TARGET". **--prev-pipeline-event**="": Set the metadata environment variable "CI_PREV_PIPELINE_EVENT". **--prev-pipeline-finished**="": Set the metadata environment variable "CI_PREV_PIPELINE_FINISHED". (default: 0) **--prev-pipeline-number**="": Set the metadata environment variable "CI_PREV_PIPELINE_NUMBER". (default: 0) **--prev-pipeline-started**="": Set the metadata environment variable "CI_PREV_PIPELINE_STARTED". (default: 0) **--prev-pipeline-status**="": Set the metadata environment variable "CI_PREV_PIPELINE_STATUS". **--prev-pipeline-url**="": Set the metadata environment variable "CI_PREV_PIPELINE_FORGE_URL". **--repo**="": Set the full name to derive metadata environment variables "CI_REPO", "CI_REPO_NAME" and "CI_REPO_OWNER". **--repo-clone-ssh-url**="": Set the metadata environment variable "CI_REPO_CLONE_SSH_URL". **--repo-clone-url**="": Set the metadata environment variable "CI_REPO_CLONE_URL". **--repo-default-branch**="": Set the metadata environment variable "CI_REPO_DEFAULT_BRANCH". (default: main) **--repo-path**="": path to local repository **--repo-private**="": Set the metadata environment variable "CI_REPO_PRIVATE". **--repo-remote-id**="": Set the metadata environment variable "CI_REPO_REMOTE_ID". **--repo-trusted-network**: Set the metadata environment variable "CI_REPO_TRUSTED_NETWORK". (default: false) **--repo-trusted-security**: Set the metadata environment variable "CI_REPO_TRUSTED_SECURITY". (default: false) **--repo-trusted-volumes**: Set the metadata environment variable "CI_REPO_TRUSTED_VOLUMES". (default: false) **--repo-url**="": Set the metadata environment variable "CI_REPO_URL". **--secrets**="": map of secrets, ex. 'secret="val",secret2="value2"' **--secrets-file**="": path to yaml file with secrets map **--system-host**="": Set the metadata environment variable "CI_SYSTEM_HOST". **--system-name**="": Set the metadata environment variable "CI_SYSTEM_NAME". (default: woodpecker) **--system-platform**="": Set the metadata environment variable "CI_SYSTEM_PLATFORM". **--system-url**="": Set the metadata environment variable "CI_SYSTEM_URL". (default: https://github.com/woodpecker-ci/woodpecker) **--timeout**="": pipeline timeout (default: 1h0m0s) **--volumes**="": pipeline volumes **--workflow-name**="": Set the metadata environment variable "CI_WORKFLOW_NAME". **--workflow-number**="": Set the metadata environment variable "CI_WORKFLOW_NUMBER". (default: 0) **--workspace-base**="": (default: /woodpecker) **--workspace-path**="": (default: src) ## info show information about the current user **--format**="": format output (deprecated) (default: User: {{ .Login }}\nEmail: {{ .Email }}) ## lint lint a pipeline configuration file **--plugins-privileged**="": allow plugins to run in privileged mode, if set empty, there is no **--plugins-trusted-clone**="": plugins that are trusted to handle Git credentials in cloning steps (default: "docker.io/woodpeckerci/plugin-git:2.9.0", "docker.io/woodpeckerci/plugin-git", "quay.io/woodpeckerci/plugin-git") **--strict**: treat warnings as errors (default: false) ## org manage organizations ### registry manage organization registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--password**="": registry password **--username**="": registry username ### secret manage secrets #### add add a secret **--event**="": secret limited to these events **--image**="": secret limited to these images **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--value**="": secret value #### rm remove a secret **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) #### update update a secret **--event**="": limit secret to these event **--image**="": limit secret to these image **--name**="": secret name **--organization, --org**="": organization id or full name (e.g. 123 or octocat) **--value**="": secret value ## pipeline manage pipelines ### approve approve a pipeline ### create create new pipeline **--branch**="": branch to create pipeline from **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) **--var**="": key=value ### decline decline a pipeline ### deploy trigger a pipeline with the 'deployment' event **--branch**="": branch filter **--event**="": event filter (default: push) **--format**="": format output (default: Number: {{ .Number }}\nStatus: {{ .Status }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nMessage: {{ .Message }}\nAuthor: {{ .Author }}\nTarget: {{ .Deploy }}\n) **--param, -p**="": custom parameters to inject into the step environment. Format: KEY=value **--status**="": status filter (default: success) ### last show latest pipeline information **--branch**="": branch name (default: main) **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### ls show pipeline history **--after**="": only return pipelines after this date (RFC3339) **--before**="": only return pipelines before this date (RFC3339) **--branch**="": branch filter **--event**="": event filter **--limit**="": limit the list size (default: 25) **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) **--status**="": status filter ### log manage logs #### purge purge a log #### show show pipeline logs ### ps show pipeline steps **--format**="": format output (default: \x1b[33m{{ .workflow.Name }} > {{ .step.Name }} (#{{ .step.PID }}):\x1b[0m\nStep: {{ .step.Name }}\nStarted: {{ .step.Started }}\nStopped: {{ .step.Stopped }}\nType: {{ .step.Type }}\nState: {{ .step.State }}\n) ### purge purge pipelines **--branch**="": remove pipelines of this branch only **--dry-run**: disable non-read api calls (default: false) **--keep-min**="": minimum number of pipelines to keep (default: 10) **--older-than**="": remove pipelines older than the specified time limit (default: 0s) ### queue show pipeline queue **--format**="": format output (default: \x1b[33m{{ .FullName }} #{{ .Number }} \x1b[0m\nStatus: {{ .Status }}\nEvent: {{ .Event }}\nCommit: {{ .Commit }}\nBranch: {{ .Branch }}\nRef: {{ .Ref }}\nAuthor: {{ .Author }} {{ if .Email }}<{{.Email}}>{{ end }}\nMessage: {{ .Message }}\n) ### show show pipeline information **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### start start a pipeline **--param, -p**="": custom parameters to inject into the step environment. Format: KEY=value ### stop stop a pipeline ## repo manage repositories ### add add a repository ### chown assume ownership of a repository ### cron manage cron jobs #### add add a cron job **--branch**="": cron branch **--enabled**: whether cron is enabled (default: true) **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule #### rm remove a cron job **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list cron jobs **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show cron job information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--id**="": cron id **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a cron job **--branch**="": cron branch **--enabled**: whether cron is enabled (default: true) **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nID: {{ .ID }}\nBranch: {{ .Branch }}\nSchedule: {{ .Schedule }}\nNextExec: {{ .NextExec }}\n) **--id**="": cron id **--name**="": cron name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--schedule**="": cron schedule ### ls list all repos **--all**: query all repos, including inactive ones (default: false) **--format**="": format output (deprecated) **--org**="": filter by organization **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### registry manage registries #### add add a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username #### rm remove a registry **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list registries **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show registry information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Address }} \x1b[0m\nUsername: {{ .Username }}\nEmail: {{ .Email }}\n) **--hostname**="": registry hostname (default: docker.io) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a registry **--hostname**="": registry hostname (default: docker.io) **--password**="": registry password **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--username**="": registry username ### rm remove a repository ### repair repair repository webhooks ### secret manage secrets #### add add a secret **--event**="": limit secret to these events **--image**="": limit secret to these images **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value #### rm remove a secret **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### ls list secrets **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### show show secret information **--format**="": format output (deprecated) (default: \x1b[33m{{ .Name }} \x1b[0m\nEvents: {{ list .Events }}\n{{- if .Images }}\nImages: {{ list .Images }}\n{{- else }}\nImages: \n{{- end }}\n) **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) #### update update a secret **--event**="": limit secret to these events **--image**="": limit secret to these images **--name**="": secret name **--repository, --repo**="": repository id or full name (e.g. 134 or octocat/hello-world) **--value**="": secret value ### show show repository information **--output**="": output format (default: table) **--output-no-headers**: don't print headers (default: false) ### sync synchronize the repository list **--format**="": format output (default: \x1b[33m{{ .FullName }}\x1b[0m (id: {{ .ID }}, forgeRemoteID: {{ .ForgeRemoteID }}, isActive: {{ .IsActive }})) ### update update a repository **--config**="": repository configuration path. Example: .woodpecker.yml **--pipeline-counter**="": repository starting pipeline number (default: 0) **--require-approval**="": repository requires approval for **--timeout**="": repository timeout (default: 0s) **--trusted-network**: repository is network trusted (default: false) **--trusted-security**: repository is security trusted (default: false) **--trusted-volumes**: repository is volumes trusted (default: false) **--unsafe**: allow unsafe operations (default: false) **--visibility**="": repository visibility ## setup setup the woodpecker-cli for the first time **--context, --ctx**="": name for the context (defaults to 'default') **--server**="": URL of the woodpecker server **--token**="": token to authenticate with the woodpecker server ## update update the woodpecker-cli to the latest version **--force**: force update even if the latest version is already installed (default: false) ================================================ FILE: docs/versioned_docs/version-3.14/92-development/01-getting-started.md ================================================ # Getting started You can develop on your local computer by following the [steps below](#preparation-for-local-development) or you can start with a fully prepared online setup using [Gitpod](https://github.com/gitpod-io/gitpod) and [Gitea](https://github.com/go-gitea/gitea). ## Gitpod If you want to start development or updating docs as easy as possible, you can use our pre-configured setup for Woodpecker using [Gitpod](https://github.com/gitpod-io/gitpod). Gitpod starts a complete development setup in the cloud containing: - An IDE in the browser or bridged to your local VS-Code or Jetbrains - A pre-configured [Gitea](https://github.com/go-gitea/gitea) instance as forge - A pre-configured Woodpecker server - A single pre-configured Woodpecker agent node - Our docs preview server Start Woodpecker in Gitpod by clicking on the following badge. You can log in with `woodpecker` and `password`. [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/woodpecker-ci/woodpecker) ## Preparation for local development ### Install Go Install Golang as described by [this guide](https://go.dev/doc/install). ### Install make > GNU Make is a tool which controls the generation of executables and other non-source files of a program from the program's source files (). Install make on: - Ubuntu: `apt install make` - [Docs](https://wiki.ubuntuusers.de/Makefile/) - [Windows](https://stackoverflow.com/a/32127632/8461267) - Mac OS: `brew install make` ### Install Node.js & `pnpm` Install [Node.js](https://nodejs.org/en/download/package-manager) if you want to build Woodpecker's UI or documentation. For dependency installation (`node_modules`) of UI and documentation of Woodpecker the package manager pnpm is used. [This guide](https://pnpm.io/installation) describes the installation of `pnpm`. ### Install `pre-commit` (optional) Woodpecker uses [`pre-commit`](https://pre-commit.com/) to allow you to easily autofix your code. To apply it during local development, take a look at [`pre-commit`s documentation](https://pre-commit.com/#usage). ### Create a `.env` file with your development configuration Similar to the environment variables you can set for your production setup of Woodpecker, you can create a `.env` file in the root of the Woodpecker project and add any needed config to it. A common config for debugging would look like this: ```ini WOODPECKER_OPEN=true WOODPECKER_ADMIN=your-username WOODPECKER_HOST=http://localhost:8000 # github (sample for a forge config - see /docs/administration/forge/overview for other forges) WOODPECKER_GITHUB=true WOODPECKER_GITHUB_CLIENT= WOODPECKER_GITHUB_SECRET= # agent WOODPECKER_SERVER=localhost:9000 WOODPECKER_AGENT_SECRET=a-long-and-secure-password-used-for-the-local-development-system WOODPECKER_MAX_WORKFLOWS=1 # enable if you want to develop the UI # WOODPECKER_DEV_WWW_PROXY=http://localhost:8010 # if you want to test webhooks with an online forge like GitHub this address needs to be set and accessible from public server WOODPECKER_EXPERT_WEBHOOK_HOST=http://your-address.com # disable health-checks while debugging (normally not needed while developing) WOODPECKER_HEALTHCHECK=false # WOODPECKER_LOG_LEVEL=debug # WOODPECKER_LOG_LEVEL=trace ``` ### Setup OAuth Create an OAuth app for your forge as described in the [forges documentation](../30-administration/10-configuration/12-forges/11-overview.md). ## Developing with VS Code You can use different methods for debugging the Woodpecker applications. One of the currently recommended ways to debug and test the Woodpecker application is using [VS-Code](https://code.visualstudio.com/) or [VS-Codium](https://vscodium.com/) (Open-Source binaries of VS-Code) as most maintainers are using it and Woodpecker already includes the needed debug configurations for it. To launch all needed services for local development, you can use "Woodpecker CI" debugging configuration that will launch UI, server and agent in debugging mode. Then open `http://localhost:8000` to access it. As a starting guide for programming Go with VS Code, you can use this video guide: [![Getting started with Go in VS Code](https://img.youtube.com/vi/1MXIGYrMk80/0.jpg)](https://www.youtube.com/watch?v=1MXIGYrMk80) ### Debugging Woodpecker The Woodpecker source code already includes launch configurations for the Woodpecker server and agent. To start debugging you can click on the debug icon in the navigation bar of VS-Code (ctrl-shift-d). On that page you will see the existing launch jobs at the top. Simply select the agent or server and click on the play button. You can set breakpoints in the source files to stop at specific points. ![Woodpecker debugging with VS Code](./vscode-debug.png) ## Testing & linting code To test or lint parts of Woodpecker, you can run one of the following commands: ```bash # test server code make test-server # test agent code make test-agent # test cli code make test-cli # test datastore / database related code like migrations of the server make test-server-datastore # lint go code make lint # lint UI code make lint-frontend # test UI code make test-frontend ``` If you want to test a specific Go file, you can also use: ```bash go test -race -timeout 30s go.woodpecker-ci.org/woodpecker/v3/ ``` Or you can open the test-file inside [VS-Code](#developing-with-vs-code) and run or debug the test by clicking on the inline commands: ![Run test via VS-Code](./vscode-run-test.png) ## Run applications from terminal If you want to run a Woodpecker applications from your terminal, you can use one of the following commands from the base of the Woodpecker project. They will execute Woodpecker in a similar way as described in [debugging Woodpecker](#debugging-woodpecker) without the ability to really debug it in your editor. ```bash title="start server" go run ./cmd/server ``` ```bash title="start agent" go run ./cmd/agent ``` ```bash title="execute cli command" go run ./cmd/cli [command] ``` ================================================ FILE: docs/versioned_docs/version-3.14/92-development/02-core-ideas.md ================================================ # Core ideas - A configuration (e.g. of a pipeline) should never be [turing complete](https://en.wikipedia.org/wiki/Turing_completeness) (We have agents to exec things 🙂). - If possible, follow the [KISS principle](https://en.wikipedia.org/wiki/KISS_principle). - What is used most often should be default. - Keep different topics separated, so you can write plugins, port new ideas ... more easily, see [Architecture](./05-architecture.md). ## Addons and extensions If you are wondering whether your contribution will be accepted to be merged in the Woodpecker core, or whether it's better to write an [addon](../30-administration/10-configuration/100-addons.md), [extension](../20-usage/72-extensions/40-configuration-extension.md) or an [external custom backend](../30-administration/10-configuration/11-backends/50-custom.md), please check these points: - Is your change very specific to your setup and unlikely to be used by anyone else? - Does your change violate the [guidelines](#guidelines)? Both should be false when you open a pull request to get your change into the core repository. ### Guidelines #### Forges A new forge must support these features: - OAuth2 - Webhooks ================================================ FILE: docs/versioned_docs/version-3.14/92-development/03-ui.md ================================================ # UI Development To develop the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). In addition it is recommended to use VS-Code with the recommended plugin selection to get features like auto-formatting, linting and typechecking. The UI is written with [Vue 3](https://v3.vuejs.org/) as Single-Page-Application accessing the Woodpecker REST api. ## Setup The UI code is placed in `web/`. Change to that folder in your terminal with `cd web/` and install all dependencies by running `pnpm install`. For production builds the generated UI code is integrated into the Woodpecker server by using [go-embed](https://pkg.go.dev/embed). Testing UI changes would require us to rebuild the UI after each adjustment to the code by running `pnpm build` and restarting the Woodpecker server. To avoid this you can make use of the dev-proxy integrated into the Woodpecker server. This integrated dev-proxy will forward all none api request to a separate http-server which will only serve the UI files. ![UI Proxy architecture](./ui-proxy.svg) Start the UI server locally with [hot-reloading](https://stackoverflow.com/a/41429055/8461267) by running: `pnpm start`. To enable the forwarding of requests to the UI server you have to enable the dev-proxy inside the Woodpecker server by adding `WOODPECKER_DEV_WWW_PROXY=http://localhost:8010` to your `.env` file. After starting the Woodpecker server as explained in the [debugging](./01-getting-started.md#debugging-woodpecker) section, you should now be able to access the UI under [http://localhost:8000](http://localhost:8000). ### Usage with remote server If you would like to test your UI changes on a "real-world" Woodpecker server which probably has more complex data than local test instances, you can run `pnpm start` with these environment variables: - `VITE_DEV_PROXY`: your server URL, for example `https://ci.woodpecker-ci.org` - `VITE_DEV_USER_SESS_COOKIE`: the value `user_sess` cookie in your browser Then, open the UI at `http://localhost:8010`. ## Tools and frameworks The following list contains some tools and frameworks used by the Woodpecker UI. For some points we added some guidelines / hints to help you developing. - [Vue 3](https://v3.vuejs.org/) - use `setup` and composition api - place (re-usable) components in `web/src/components/` - views should have a route in `web/src/router.ts` and are located in `web/src/views/` - [Tailwind CSS](https://tailwindcss.com/) - use Tailwind classes where possible - if needed extend the Tailwind config to use new classes - classes are sorted following the [prettier tailwind sort plugin](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier) - [Vite](https://vitejs.dev/) (similar to Webpack) - [Typescript](https://www.typescriptlang.org/) - avoid using `any` and `unknown` (the linter will prevent you from doing so anyways :wink:) - [eslint](https://eslint.org/) - [Volar & vue-tsc](https://github.com/johnsoncodehk/volar/) for type-checking in .vue file - use the take-over mode of Volar as described by [this guide](https://github.com/johnsoncodehk/volar/discussions/471) ## Messages and Translations Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. New translations have to be added to `web/src/assets/locales/en.json`. The English source file will be automatically imported into [Weblate](https://translate.woodpecker-ci.org/) (the translation system used by Woodpecker) where all other languages will be translated by the community based on the English source. You must not provide translations except English in PRs, otherwise weblate could put git into conflicts (when someone has translated in that language file and changes are not into main branch yet) For more information about translations see [Translations](./08-translations.md). ================================================ FILE: docs/versioned_docs/version-3.14/92-development/04-docs.md ================================================ # Documentation The documentation is using docusaurus as framework. You can learn more about it from its [official documentation](https://docusaurus.io/docs/). If you only want to change some text it probably is enough if you just search for the corresponding [Markdown](https://www.markdownguide.org/basic-syntax/) file inside the `docs/docs/` folder and adjust it. If you want to change larger parts and test the rendered documentation you can run docusaurus locally. Similarly to the UI you need to install [Node.js and pnpm](./01-getting-started.md#install-nodejs--pnpm). After that you can run and build docusaurus locally by using the following commands: ```bash cd docs/ pnpm install # build plugins used by the docs pnpm build:woodpecker-plugins # start docs with hot-reloading, so you can change the docs and directly see the changes in the browser without reloading it manually pnpm start # or build the docs to deploy it to some static page hosting pnpm build ``` ================================================ FILE: docs/versioned_docs/version-3.14/92-development/05-architecture.md ================================================ # Architecture ## Module Interactions ![Woodpecker architecture](./woodpecker-architecture.svg) ## System architecture ### main package hierarchy | package | meaning | imports | | ------------------ | -------------------------------------------------------------- | ------------------------------------- | | `cmd/**` | parse command-line args & environment to stat server/cli/agent | all other | | `agent/**` | code only agent (remote worker) will need | `pipeline`, `rpc`, `shared` | | `cli/**` | code only cli tool does need | `pipeline`, `shared`, `woodpecker-go` | | `server/**` | code only server will need | `pipeline`, `rpc`, `shared` | | `pipeline/**` | core ci/cd engine from parsing to execution | `shared` | | `rpc/**` | RPC interface for agent-server communication | `pipeline` | | `shared/**` | code shared for all three main tools (go help utils) | only std and external libs | | `woodpecker-go/**` | go client for server rest api | std | ### Server | package | meaning | imports | | -------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server/api/**` | handle web requests from `server/router` | `pipeline`, `rpc`, `../badges`, `../ccmenu`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../shared`, `../store`, `shared`, (TODO: mv `server/router/middleware/session`) | | `server/badges/**` | generate svg badges for pipelines | `../model` | | `server/ccmenu/**` | generate xml ccmenu for pipelines | `../model` | | `server/rpc/**` | gRPC server agents can connect to | `rpc`, `../logging`, `../model`, `../pubsub`, `../queue`, `../forge`, `../pipeline`, `../store` | | `server/logging/**` | logging lib for gPRC server to stream logs while running | std | | `server/model/**` | structs for store (db) and api (json) | std | | `server/pipeline/**` | orchestrate pipelines (TODO: parts of it should move into /pipeline) | `pipeline`, `../model`, `../pubsub`, `../queue`, `../forge`, `../store`, `../plugins` | | `server/pubsub/**` | pubsub lib for server to push changes to the WebUI | std | | `server/queue/**` | queue lib for server where agents pull new pipelines from via gRPC | `server/model` | | `server/forge/**` | forge lib for server to connect and handle forge specific stuff | `shared`, `server/model` | | `server/router/**` | handle requests to REST API (and all middleware) and serve UI and WebUI config | `shared`, `../api`, `../model`, `../forge`, `../store`, `../web` | | `server/store/**` | handle database | `server/model` | | `server/web/**` | server SPA | | - `../` = `server/` ### Agent | package | meaning | imports | | -------------- | ---------------------------------------------------- | ------------------------------------------------------ | | `agent/**` | agent implementation that runs workflows | `pipeline`, `rpc`, `shared` | | `agent/rpc/**` | gRPC client for agent-server communication | `rpc`, `pipeline/backend/types`, std and external libs | | `cmd/agent/**` | CLI interface for starting and configuring the agent | `agent`, std and external libs | The agent is a remote worker that connects to the server via gRPC to receive pipeline execution instructions and report back execution state and logs. The agent polls the server's queue for new work, executes pipeline steps using the pipeline engine, and streams results back to the server. TODO: Review cmd/agent/core to determine if any logic should be moved into the agent package for better separation of concerns. ### CLI | package | meaning | imports | | ------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | `cli/admin/**` | admin commands for server management (users, secrets, registries, etc.) | `../common`, `../internal`, `woodpecker-go` | | `cli/common/**` | shared utilities and helpers used across all CLI subcommands | `../internal/config`, `../update`, `shared` | | `cli/context/**` | manage multiple server contexts (connections to different servers) | `../common`, `../internal/config`, `../output` | | `cli/exec/**` | execute pipelines locally without server orchestration | `pipeline`, `../common`, `../lint`, `shared` | | `cli/info/**` | display information about the current user | `../common`, `../internal` | | `cli/internal/**` | internal utilities for HTTP client, auth, and server communication | `../internal/config`, `woodpecker-go`, `shared` | | `cli/internal/config/**` | configuration file management (load, store, credentials) | std and external libs | | `cli/lint/**` | validate pipeline configuration files | `pipeline/frontend/yaml`, `pipeline/frontend/yaml/linter`, `../common`, `shared` | | `cli/org/**` | manage organization-level resources (secrets, registries) | `../common`, `../internal`, `woodpecker-go` | | `cli/output/**` | formatting utilities for CLI output (tables, etc.) | std and external libs | | `cli/pipeline/**` | manage pipeline operations (start, stop, approve, logs, etc.) | `../common`, `../internal`, `../output`, `woodpecker-go`, `shared` | | `cli/repo/**` | manage repository-level resources (repos, crons, secrets, registries) | `../common`, `../internal`, `../output`, `woodpecker-go` | | `cli/setup/**` | interactive first-time setup wizard for CLI configuration | `../internal/config` | | `cli/update/**` | self-updater for the CLI binary | std and external libs | | `cmd/cli/**` | CLI entry point and command structure | `cli/**` | The CLI provides a command-line interface for interacting with Woodpecker servers. Each subcommand is organized into its own package under `cli//`. The `cli/exec` subcommand allows local pipeline execution for testing and development by combining pipeline parsing and execution without requiring a running server or agent. - `../` = `cli/` ### Engine The engine is the shared kernel that validates, parses frontend facing config files, enrich it by the provided forge metadata and produce config for the backends to execute on based on that. It also contains the default backend implementations. #### Runtime The runtime is the package controlling how a workflow is executed, and can be found at `pipeline/runtime`. Pipeline/runtime flow diagram ================================================ FILE: docs/versioned_docs/version-3.14/92-development/06-conventions.md ================================================ # Conventions ## Database naming Database tables are named plural, columns don't have any prefix. Example: Model name `Agent` with table name `agents` and columns `id`, `name`. ================================================ FILE: docs/versioned_docs/version-3.14/92-development/07-guides.md ================================================ # Guides ## ORM Woodpecker uses [Xorm](https://xorm.io/) as ORM for the database connection. ## Add a new migration Woodpecker uses migrations to change the database schema if a database model has been changed. Add the new migration task into `server/store/datastore/migration/`. :::info Adding new properties to models will be handled automatically by the underlying [ORM](#orm) based on the [struct field tags](https://stackoverflow.com/questions/10858787/what-are-the-uses-for-tags-in-go) of the model. If you add a completely new model, you have to add it to the `allBeans` variable at `server/store/datastore/migration/migration.go` to get a new table created. ::: :::warning You should not use `sess.Begin()`, `sess.Commit()` or `sess.Close()` inside a migration. Session / transaction handling will be done by the underlying migration manager. ::: To automatically execute the migration after the start of the server, the new migration needs to be added to the end of `migrationTasks` in `server/store/datastore/migration/migration.go`. After a successful execution of that transaction the server will automatically add the migration to a list, so it won't be executed again on the next start. ## Constants of official images All official default images, are saved in [shared/constant/constant.go](https://github.com/woodpecker-ci/woodpecker/blob/main/shared/constant/constant.go) and must be pinned by an exact tag. ## Building images locally ### Server ```sh ### build web component make vendor cd web/ pnpm install --frozen-lockfile pnpm build cd .. ### define the platforms to build for (e.g. linux/amd64) # (the | is not a typo here) export PLATFORMS='linux|amd64' make cross-compile-server ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.server.multiarch.rootless --push . ``` :::info The `cross-compile-server` rule makes use of `xgo`, a go cross-compiler. You need to be on a `amd64` host to do this, as `xgo` is only available for `amd64` (see [xgo#213](https://github.com/techknowlogick/xgo/issues/213)). You can try to use the `build-server` rule instead, however this one fails for some OS (e.g. macOS). ::: ### Agent ```sh ### build the agent make build-agent ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.agent.multiarch --push . ``` ### CLI ```sh ### build the CLI make build-cli ### build the image docker buildx build --platform linux/amd64 -t username/repo:tag -f docker/Dockerfile.cli.multiarch.rootless --push . ``` ================================================ FILE: docs/versioned_docs/version-3.14/92-development/08-translations.md ================================================ # Translations To translate the web UI into your language, we have [our own Weblate instance](https://translate.woodpecker-ci.org/). Please register there and translate Woodpecker into your language. **We won't accept PRs changing any language except English.** Translation status Woodpecker uses [Vue I18n](https://vue-i18n.intlify.dev/) as translation library. ================================================ FILE: docs/versioned_docs/version-3.14/92-development/09-openapi.md ================================================ # Swagger, API Spec and Code Generation Woodpecker uses [gin-swagger](https://github.com/swaggo/gin-swagger) middleware to automatically generate Swagger v2 API specifications and a nice looking Web UI from the source code. Also, the generated spec will be transformed into Markdown, using [go-swagger](https://github.com/go-swagger/go-swagger) and then being using on the community's website documentation. It's paramount important to keep the gin handler function's godoc documentation up-to-date, to always have accurate API documentation. Whenever you change, add or enhance an API endpoint, please update the godoc. You don't require any extra tools on your machine, all Swagger tooling is automatically fetched by standard Go tools. ## Gin-Handler API documentation guideline Here's a typical example of how annotations for Swagger documentation look like... ```go title="server/api/user.go" // @Summary Get a user // @Description Returns a user with the specified login name. Requires admin rights. // @Router /users/{login} [get] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param foobar query string false "optional foobar parameter" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) ``` ```go title="server/model/user.go" type User struct { ID int64 `json:"id" xorm:"pk autoincr 'user_id'"` // ... } // @name User ``` These guidelines aim to have consistent wording in the OpenAPI doc: - first word after `@Summary` and `@Summary` are always uppercase - `@Summary` has no `.` (dot) at the end of the line - model structs shall use custom short names, to ease life for API consumers, using `@name` - `@Success` object or array declarations shall be short, this means the actual `model.User` struct must have a `@name` annotation, so that the model can be rendered in OpenAPI - when pagination is used, `@Param page` and `@Param perPage` must be added manually - `@Param Authorization` is almost always present, there are just a few un-protected endpoints There are many examples in the `server/api` package, which you can use a blueprint. More enhanced information you can find here ### Manual code generation ```bash title="generate the server's Go code containing the OpenAPI" make generate-openapi ``` ```bash title="update the Markdown in the ./docs folder" make generate-docs ``` ================================================ FILE: docs/versioned_docs/version-3.14/92-development/09-testing.md ================================================ # Testing ## Backend ### Unit Tests [We use default golang unit tests](https://go.dev/doc/tutorial/add-a-test) with [`"github.com/stretchr/testify/assert"`](https://pkg.go.dev/github.com/stretchr/testify/assert) to simplify testing. ### Integration Tests ### Dummy backend There is a special backend called **`dummy`** which does not execute any commands, but emulates how a typical backend should behave. To enable it you need to build the agent or cli with the `test` build tag. An example pipeline config would be: ```yaml when: event: manual steps: - name: echo image: dummy commands: echo "hello woodpecker" environment: SLEEP: '1s' services: echo: image: dummy commands: echo "i am a service" ``` This could be executed via `woodpecker-cli --log-level trace exec --backend-engine dummy example.yaml`: ```none 9:18PM DBG pipeline/pipeline.go:94 > executing 2 stages, in order of: CLI=exec 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=0 Steps=echo 9:18PM DBG pipeline/pipeline.go:104 > stage CLI=exec StagePos=1 Steps=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:75 > create workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: service [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1A2DNQN9 [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM DBG pipeline/pipeline.go:176 > prepare CLI=exec step=echo 9:18PM DBG pipeline/pipeline.go:203 > executing CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:81 > start step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:167 > tail logs of step echo taskUUID=01J10P578JQE6E25VV1EQF0745 [echo:L0:0s] StepName: echo [echo:L1:0s] StepType: commands [echo:L2:0s] StepUUID: 01J10P578JQE6E25VV1DFSXX1Y [echo:L3:0s] StepCommands: [echo:L4:0s] ------------------ [echo:L5:0s] echo ja [echo:L6:0s] ------------------ [echo:L7:0s] 9:18PM TRC pipeline/backend/dummy/dummy.go:108 > wait for step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM TRC pipeline/backend/dummy/dummy.go:187 > stop step echo taskUUID=01J10P578JQE6E25VV1EQF0745 9:18PM DBG pipeline/pipeline.go:209 > complete CLI=exec step=echo 9:18PM TRC pipeline/backend/dummy/dummy.go:208 > delete workflow environment taskUUID=01J10P578JQE6E25VV1EQF0745 ``` There are also environment variables to alter step behavior: - `SLEEP: 10` will let the step wait 10 seconds - `EXPECT_TYPE` allows to check if a step is a `clone`, `service`, `plugin` or `commands` - `STEP_START_FAIL: true` if set will simulate a step to fail before actually being started (e.g. happens when the container image can not be pulled) - `STEP_TAIL_FAIL: true` if set will error when we simulate to read from stdout for logs - `STEP_EXIT_CODE: 2` if set will be used as exit code, default is 0 - `STEP_OOM_KILLED: true` simulates a step being killed by memory constrains You can let the setup of a whole workflow fail by setting it's UUID to `WorkflowSetupShouldFail`. ================================================ FILE: docs/versioned_docs/version-3.14/92-development/10-packaging.md ================================================ # Packaging If you repackage it, we encourage to build from source, which requires internet connection. For offline builds, we also offer a tarball with all vendored dependencies and a pre-built web UI on the [release page](https://github.com/woodpecker-ci/woodpecker/releases). ## Distribute web UI in own directory If you do not want to embed the web UI in the binary, you can compile a custom root path for the web UI into the binary. Add `external_web` to the tags and use the build flag `-X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/some/path` to set a custom path. Example: ```sh go build -tags 'external_web' -ldflags '-s -w -extldflags "-static" -X go.woodpecker-ci.org/woodpecker/v3/version.Version=3.12.0 -X go.woodpecker-ci.org/woodpecker/v3/web.webUIRoot=/nix/store/maaajlp8h5gy9zyjgfhaipzj07qnnmrl-woodpecker-WebUI-3.12.0' -o dist/woodpecker-server go.woodpecker-ci.org/woodpecker/v3/cmd/server ``` ================================================ FILE: docs/versioned_docs/version-3.14/92-development/100-addons.md ================================================ # Addons The Woodpecker server supports addons for forges and the log store. :::warning Addons are still experimental. Their implementation can change and break at any time. ::: ## Bug reports If you experience bugs, please check which component has the issue. If it's the addon, **do not raise an issue in the main repository**, but rather use the separate addon repositories. To check which component is responsible for the bug, look at the logs. Logs from addons are marked with a special field `addon` containing their addon file name. ## Creating addons Addons use RPC to communicate to the server and are implemented using the [`go-plugin` library](https://github.com/hashicorp/go-plugin). ### Writing your code This example will use the Go language. Directly import Woodpecker's Go packages (`go.woodpecker-ci.org/woodpecker/v3`) and use the interfaces and types defined there. In the `main` function, just call the `Serve` method in the corresponding [addon package](#addon-types) with the service as argument. This will take care of connecting the addon forge to the server. :::note It is not possible to access global variables from Woodpecker, for example the server configuration. You must therefore parse the environment variables in your addon. The reason for this is that the addon runs in a completely separate process. ::: ### Example structure This is an example for a forge addon. ```go package main import ( "context" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/forge/addon" forgeTypes "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func main() { addon.Serve(config{}) } type config struct { } // `config` must implement `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge`. You must directly use Woodpecker's packages - see imports above. ``` ### Addon types | Type | Addon package | Service interface | | --------- | ------------------------------------------------------------- | ----------------------------------------------------------------- | | Forge | `go.woodpecker-ci.org/woodpecker/v3/server/forge/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/forge".Forge` | | Log store | `go.woodpecker-ci.org/woodpecker/v3/server/service/log/addon` | `"go.woodpecker-ci.org/woodpecker/v3/server/service/log".Service` | ================================================ FILE: docs/versioned_docs/version-3.14/92-development/40-deprecations.md ================================================ # Deprecation Policy ## Pipeline Configuration Changes Pipeline configuration (YAML syntax) changes follow a strict deprecation process to ensure users have sufficient time to migrate. ### Process Timeline 1. **Minor Version N.x - Add Deprecation Warning** - Linter shows a warning (not an error) - Old syntax remains functional - Documentation is updated to reflect the new syntax - Warning message includes guidance on required changes 2. **Major Version (N+1).0 - Warning Becomes Error** - Linter issues an error (pipeline fails) - Old syntax is no longer supported - Breaking change is documented in the migration guide - Users **must** update their configurations 3. **Minor Version (N+1).x - Code Cleanup** - Deprecated code paths are removed - Implementation is simplified/refactored - Parser no longer recognizes the old syntax ### Example Old syntax: `secrets: [token]` New syntax: `environment: { TOKEN: { from_secret: token } }` - **v2.5.0:** Deprecation warning added in linter; both syntaxes work - **v2.6-2.9:** Warning persists; both syntaxes remain functional - **v3.0.0:** Linter error; old syntax fails (breaking change) - **v3.1.0:** Deprecated code paths removed; parser simplified ### Implementation Checklist When deprecating pipeline configuration syntax, ensure the following: - [ ] Add linter warning in `/pipeline/frontend/yaml/linter/` - [ ] Update JSON schema in `/pipeline/frontend/yaml/linter/schema` - [ ] Add test cases for deprecated syntax - [ ] Update documentation to reflect the new syntax ================================================ FILE: docs/versioned_docs/version-3.14/92-development/_category_.yaml ================================================ label: 'Development' # position: 3 collapsible: true collapsed: true ================================================ FILE: docs/versioned_docs/version-3.14/92-development/woodpecker-architecture.dot ================================================ digraph WoodpeckerArchitecture { graph [ rankdir=TB, splines=ortho, nodesep=0.5, ranksep=0.8, fontname="Helvetica" ] node [ shape=box, style="rounded,filled", fillcolor="#2b2b2b", fontcolor="white", fontname="Helvetica" ] edge [ color="#bdbdbd", arrowsize=0.7 ] /* ===================== UI ===================== */ subgraph cluster_ui { label="UI" fillcolor="#c7efe9" fontcolor="black" style="rounded,filled" ui_web [label="web/"] } /* ===================== SDK ===================== */ subgraph cluster_sdk { label="SDK (woodpecker-go)" fillcolor="#e8f5e9" fontcolor="black" style="rounded,filled" sdk [label="woodpecker-go"] } /* ===================== CLI ===================== */ subgraph cluster_cli { label="woodpecker-cli" fillcolor="#bfe9e0" fontcolor="black" style="rounded,filled" cli_cmd [label="cmd/cli/"] cli_core [label="cli/"] } /* ===================== Agent ===================== */ subgraph cluster_agent { label="woodpecker-agent" fillcolor="#ffe0c7" fontcolor="black" style="rounded,filled" agent_cmd [label="cmd/agent/"] agent_core [label="agent/"] } /* ===================== Pipelines ===================== */ subgraph cluster_pipelines { label="Pipelines" fillcolor="#ffe8d6" fontcolor="black" style="rounded,filled" pipe_core [label="pipeline/"] pipe_frontend [label="pipeline/frontend/\n(yaml)"] pipe_backend [label="pipeline/backend/\n(exec engines)"] } /* ===================== Server ===================== */ subgraph cluster_server { label="woodpecker-server" fillcolor="#dbe9ff" fontcolor="black" style="rounded,filled" srv_cmd [label="cmd/server/"] srv_router [label="server/router/"] srv_api [label="server/api/"] srv_grpc [label="server/rpc/"] srv_queue [label="server/queue/"] srv_pubsub [label="server/pubsub/"] srv_store [label="server/store/"] srv_model [label="server/model/"] srv_forge [label="server/forge/"] } /* ===================== Shared Libs ===================== */ subgraph cluster_shared { label="Shared Libs" fillcolor="#eeeeee" fontcolor="black" style="rounded,filled" shared_util [label="shared/util/"] shared_token [label="shared/token/"] shared_http [label="shared/httputil/"] shared_log [label="shared/logger/"] } /* ===================== External ===================== */ subgraph cluster_external { label="External Systems" style="rounded,dashed" fontcolor="white" ext_scm [label="SCM Providers", shape=cloud] ext_db [label="Database", shape=cylinder] } /* ===================== Runtime Interactions ===================== */ /* UI */ ui_web -> srv_router [xlabel="HTTP"] ui_web -> srv_api [xlabel="REST API"] /* CLI */ cli_cmd -> cli_core cli_core -> sdk sdk -> srv_api [xlabel="REST API"] /* Agent */ agent_cmd -> agent_core agent_core -> srv_grpc [xlabel="gRPC connect"] agent_core -> srv_queue [xlabel="poll work"] agent_core -> pipe_backend [xlabel="execute steps"] /* Pipelines */ pipe_frontend -> pipe_core pipe_core -> pipe_backend /* Server internal flow */ srv_cmd -> srv_router srv_router -> srv_api srv_api -> srv_store srv_api -> srv_pubsub srv_api -> srv_queue srv_grpc -> srv_queue srv_store -> srv_model /* External integrations */ srv_forge -> ext_scm [xlabel="SCM API"] srv_store -> ext_db [xlabel="SQL"] /* Shared libs usage (consumer -> library) */ srv_router -> shared_token srv_api -> shared_http srv_grpc -> shared_log pipe_core -> shared_util } ================================================ FILE: docs/versioned_sidebars/version-2.8-sidebars.json ================================================ { "tutorialSidebar": [ { "type": "autogenerated", "dirName": "." } ] } ================================================ FILE: docs/versioned_sidebars/version-3.12-sidebars.json ================================================ { "tutorialSidebar": [ { "type": "autogenerated", "dirName": "." } ] } ================================================ FILE: docs/versioned_sidebars/version-3.13-sidebars.json ================================================ { "tutorialSidebar": [ { "type": "autogenerated", "dirName": "." } ] } ================================================ FILE: docs/versioned_sidebars/version-3.14-sidebars.json ================================================ { "tutorialSidebar": [ { "type": "autogenerated", "dirName": "." } ] } ================================================ FILE: docs/versions.json ================================================ ["3.14", "3.13", "3.12", "2.8"] ================================================ FILE: e2e/scenarios/agent_routing_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package scenarios import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/e2e/setup" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) // labelRoutingYAML is a single-workflow pipeline that requires the label // gpu=true. Only the gpu-agent should pick it up; the plain agent must not. var labelRoutingYAML = []byte(` labels: gpu: "true" steps: - name: gpu-step image: dummy commands: - echo running on gpu agent `) // TestAgentLabelRouting starts two agents — one plain, one with gpu=true — // and asserts that the pipeline with labels: gpu: "true" is always picked up // by the gpu agent and never by the plain agent. func TestAgentLabelRouting(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: labelRoutingYAML}, }) // Plain agent: wildcard repo label only — cannot satisfy gpu=true. plainAgent := setup.StartAgent(t, env.GRPCAddr, setup.WithHostname("plain-agent"), ) // GPU agent: carries gpu=true — the only agent that can accept the task. gpuAgent := setup.StartAgent(t, env.GRPCAddr, setup.WithHostname("gpu-agent"), setup.WithCustomLabels(map[string]string{"gpu": "true"}), ) setup.WaitForAgentRegistered(t, env.Store, plainAgent, gpuAgent) // Ensure both agents are actively polling before enqueuing the task. // Without this, the plain agent (which polls with repo=* and no gpu label) // could theoretically win if the queue tries to assign before the gpu-agent // has connected its poll goroutines. In practice label filtering prevents a // wrong assignment here, but waiting avoids any startup-ordering flakiness. setup.WaitForWorkersReady(t, env.Queue, 2*setup.AgentMaxWorkflows) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create pipeline") finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusSuccess, finished.Status, "pipeline should succeed") // The single workflow (name="woodpecker" from SanitizePath(".woodpecker.yaml")) // must have been executed by the gpu agent, not the plain agent. setup.AssertWorkflowRanOnAgent(t, env.Store, finished, "woodpecker", gpuAgent) } /* // TODO: The agent assignment is currently flaky and so is the test, fix that. // orgPipelineYAML is a plain single-step pipeline used for org-preference tests. Var orgPipelineYAML = []byte(` steps: - name: build image: dummy commands: - echo building `) // TestOrgAgentPreferredOverGlobal starts a global agent and an org-scoped agent // for the same org as the test repo. It asserts that the org agent is always // preferred by the queue (score 10 vs 1) and picks up the pipeline. Func TestOrgAgentPreferredOverGlobal(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: orgPipelineYAML}, }) // Global agent: matches org-id=* (score 1). globalAgent := setup.StartAgent(t, env.GRPCAddr, setup.WithHostname("global-agent"), ) // Org agent: will be patched with the repo's OrgID (score 10). orgAgent := setup.StartAgent(t, env.GRPCAddr, setup.WithHostname("org-agent"), setup.WithOrgID(env.Fixtures.Repo.OrgID), ) setup.WaitForAgentRegistered(t, env.Store, globalAgent, orgAgent) // Wait until both agents have connected their poll goroutines to the queue. // The org-agent reads its OrgID label from the DB at Poll time — if we // create the pipeline before the org-agent is polling, the global agent // can steal the task first (it's already blocking on Poll and wins the // race). agentMaxWorkflows slots per agent = 8 workers total. setup.WaitForWorkersReady(t, env.Queue, 2*setup.AgentMaxWorkflows) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create pipeline") finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusSuccess, finished.Status, "pipeline should succeed") // The workflow must have been picked up by the org-scoped agent, not the // global one — the queue scores exact org-id matches 10× higher. setup.AssertWorkflowRanOnAgent(t, env.Store, finished, "woodpecker", orgAgent) }. */ ================================================ FILE: e2e/scenarios/cancel_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package scenarios import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/e2e/setup" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) // cancelPipelineYAML has one long-sleeping step followed by one that must // be skipped when the pipeline is canceled. var cancelPipelineYAML = []byte(` steps: - name: long-running image: dummy commands: - echo starting long job environment: SLEEP: "30s" - name: after-cancel image: dummy commands: - echo this should never run `) // TestCancelRunningPipeline triggers a long-running pipeline, waits for it // to enter StatusRunning, then cancels it via pipeline.Cancel and asserts: // - pipeline ends up as StatusKilled // - the running step exits with code 130 (dummy cancel convention = SIGINT) // - the subsequent step is skipped func TestCancelRunningPipeline(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: cancelPipelineYAML}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create pipeline") require.NotNil(t, created) // Wait until the agent has picked it up and set it to running. setup.WaitForPipelineStatus(t, env.Store, created.ID, model.StatusRunning, 10*time.Second) // Also wait for the specific step to reach StatusRunning in the DB. // The pipeline transitions to StatusRunning as soon as the agent starts // the workflow, but the step itself may not yet have entered its // sleepWithContext call in the dummy backend. If we cancel before the // step is actually sleeping, WaitStep returns immediately with success // before the cancel context propagates — causing "success" instead of // "killed". Waiting here ensures the dummy sleep is genuinely in progress. setup.WaitForStepRunning(t, env.Store, created.ID, "long-running") // Resolve the forge instance (MockForge) via the manager. forge, err := env.Manager.ForgeByID(env.Fixtures.Forge.ID) require.NoError(t, err, "resolve forge") // Fetch the latest pipeline state from the store before canceling. running, err := env.Store.GetPipeline(created.ID) require.NoError(t, err, "get running pipeline") // Cancel through the normal server API path — same as the HTTP handler does. err = pipeline.Cancel(t.Context(), forge, env.Store, env.Fixtures.Repo, env.Fixtures.Owner, running, nil) require.NoError(t, err, "cancel pipeline") // Wait for the pipeline to reach a terminal state. finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusKilled, finished.Status, "canceled pipeline should be killed") t.Run("long-running step is killed", func(t *testing.T) { // After pipeline.Cancel() the pipeline itself reaches a terminal state // immediately, but the running step's status is written asynchronously // by the agent's gRPC Done() call — which arrives *after* the cancel // signal is processed. We therefore wait explicitly for the step to // leave "running", giving the agent enough time to finish cleanup and // report back. step := setup.WaitForStepStatus(t, env.Store, finished, "long-running", model.StatusKilled, 30*time.Second) assert.Equal(t, model.StatusKilled, step.State) }) t.Run("after-cancel step is canceled", func(t *testing.T) { // Pending steps get StatusCanceled synchronously by pipeline.Cancel() // before any agent is involved, so this should already be set. step := setup.WaitForStep(t, env.Store, finished, "after-cancel") assert.Equal(t, model.StatusCanceled, step.State) }) } ================================================ FILE: e2e/scenarios/fixtures/01_simple_success.json ================================================ { "name": "simple success", "event": "push", "expected_status": "success", "expected_steps": [ { "name": "clone", "status": "success", "exit_code": 0 }, { "name": "build", "status": "success", "exit_code": 0 }, { "name": "test", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/01_simple_success.yaml ================================================ steps: - name: build image: dummy commands: - echo building - name: test image: dummy commands: - echo testing ================================================ FILE: e2e/scenarios/fixtures/02_step_failure.json ================================================ { "name": "step failure stops pipeline", "event": "push", "expected_status": "failure", "expected_steps": [ { "name": "build", "status": "failure", "exit_code": 1 }, { "name": "deploy", "status": "skipped", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/02_step_failure.yaml ================================================ skip_clone: true steps: - name: build image: dummy commands: - echo building environment: STEP_EXIT_CODE: '1' - name: deploy image: dummy commands: - echo deploying ================================================ FILE: e2e/scenarios/fixtures/03_failure_ignore.json ================================================ { "name": "failure ignore continues pipeline", "event": "push", "expected_status": "success", "expected_steps": [ { "name": "lint", "status": "failure", "exit_code": 1 }, { "name": "build", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/03_failure_ignore.yaml ================================================ skip_clone: true steps: - name: lint image: dummy commands: - echo linting failure: ignore environment: STEP_EXIT_CODE: '1' - name: build image: dummy commands: - echo building ================================================ FILE: e2e/scenarios/fixtures/04_on_failure_notify.json ================================================ { "name": "on-failure step runs after failure", "event": "push", "expected_status": "failure", "expected_steps": [ { "name": "build", "status": "failure", "exit_code": 2 }, { "name": "notify", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/04_on_failure_notify.yaml ================================================ skip_clone: true steps: - name: build image: dummy commands: - echo building environment: STEP_EXIT_CODE: '2' - name: notify image: dummy commands: - echo notifying when: - status: [failure] ================================================ FILE: e2e/scenarios/fixtures/05_service.json ================================================ { "name": "service runs alongside steps", "event": "push", "expected_status": "success", "expected_steps": [ { "name": "clone", "status": "success", "exit_code": 0 }, { "name": "test", "status": "success", "exit_code": 0 }, { "name": "db", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/05_service.yaml ================================================ steps: - name: test image: dummy commands: - echo running tests services: - name: db image: dummy environment: SLEEP: '100ms' ================================================ FILE: e2e/scenarios/fixtures/06_parallel_steps.json ================================================ { "name": "parallel steps with depends_on", "event": "push", "expected_status": "success", "expected_steps": [ { "name": "clone", "status": "success", "exit_code": 0 }, { "name": "test-unit", "status": "success", "exit_code": 0 }, { "name": "test-integration", "status": "success", "exit_code": 0 }, { "name": "deploy", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/06_parallel_steps.yaml ================================================ steps: - name: test-unit image: dummy commands: - echo unit tests depends_on: [] - name: test-integration image: dummy commands: - echo integration tests depends_on: [] - name: deploy image: dummy commands: - echo deploying depends_on: [test-unit, test-integration] ================================================ FILE: e2e/scenarios/fixtures/07_oom_killed.json ================================================ { "name": "OOM killed step fails pipeline", "event": "push", "expected_status": "failure", "expected_steps": [{ "name": "hungry", "status": "failure", "exit_code": 137 }] } ================================================ FILE: e2e/scenarios/fixtures/07_oom_killed.yaml ================================================ skip_clone: true steps: - name: hungry image: dummy commands: - echo eating memory environment: STEP_OOM_KILLED: 'true' STEP_EXIT_CODE: '137' ================================================ FILE: e2e/scenarios/fixtures/08_multi_step_on_failure.json ================================================ { "name": "always-run step executes on failure", "event": "push", "expected_status": "failure", "expected_steps": [ { "name": "build", "status": "failure", "exit_code": 1 }, { "name": "always-cleanup", "status": "success", "exit_code": 0 }, { "name": "deploy", "status": "skipped", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/08_multi_step_on_failure.yaml ================================================ skip_clone: true steps: - name: build image: dummy commands: - echo building environment: STEP_EXIT_CODE: '1' - name: always-cleanup image: dummy commands: - echo cleaning up when: - status: [success, failure] - name: deploy image: dummy commands: - echo deploying ================================================ FILE: e2e/scenarios/fixtures/09_multi_workflow_parallel/build.yaml ================================================ skip_clone: true steps: - name: compile image: dummy commands: - echo compiling - name: test image: dummy commands: - echo testing ================================================ FILE: e2e/scenarios/fixtures/09_multi_workflow_parallel/lint.yaml ================================================ skip_clone: true steps: - name: lint image: dummy commands: - echo linting ================================================ FILE: e2e/scenarios/fixtures/09_multi_workflow_parallel/scenario.json ================================================ { "name": "two parallel workflows both succeed", "event": "push", "expected_status": "success", "expected_workflows": [ { "name": "build", "status": "success" }, { "name": "lint", "status": "success" } ], "expected_steps": [ { "name": "compile", "status": "success", "exit_code": 0 }, { "name": "test", "status": "success", "exit_code": 0 }, { "name": "lint", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/10_multi_workflow_failure/failing.yaml ================================================ skip_clone: true steps: - name: bad-step image: dummy environment: STEP_EXIT_CODE: '1' commands: - echo this will fail ================================================ FILE: e2e/scenarios/fixtures/10_multi_workflow_failure/passing.yaml ================================================ skip_clone: true steps: - name: ok-step image: dummy commands: - echo this is fine ================================================ FILE: e2e/scenarios/fixtures/10_multi_workflow_failure/scenario.json ================================================ { "name": "one workflow fails pipeline is failure", "event": "push", "expected_status": "failure", "expected_workflows": [ { "name": "failing", "status": "failure" }, { "name": "passing", "status": "success" } ], "expected_steps": [ { "name": "ok-step", "status": "success", "exit_code": 0 }, { "name": "bad-step", "status": "failure", "exit_code": 1 } ] } ================================================ FILE: e2e/scenarios/fixtures/11_multi_workflow_failure_ignore/flaky.yaml ================================================ skip_clone: true steps: - name: flaky image: dummy environment: STEP_EXIT_CODE: '1' commands: - echo flaky step when: - failure: ignore ================================================ FILE: e2e/scenarios/fixtures/11_multi_workflow_failure_ignore/main.yaml ================================================ skip_clone: true steps: - name: build image: dummy commands: - echo building ================================================ FILE: e2e/scenarios/fixtures/11_multi_workflow_failure_ignore/scenario.json ================================================ { "name": "two workflows one fails pipeline is failure", "event": "push", "expected_status": "failure", "expected_workflows": [ { "name": "flaky", "status": "failure" }, { "name": "main", "status": "success" } ], "expected_steps": [ { "name": "build", "status": "success", "exit_code": 0 }, { "name": "flaky", "status": "failure", "exit_code": 1 } ] } ================================================ FILE: e2e/scenarios/fixtures/12_multi_workflow_depends_on/build.yaml ================================================ skip_clone: true steps: - name: compile image: dummy commands: - echo compiling - name: unit-test image: dummy commands: - echo unit testing ================================================ FILE: e2e/scenarios/fixtures/12_multi_workflow_depends_on/deploy.yaml ================================================ skip_clone: true depends_on: - build steps: - name: deploy image: dummy commands: - echo deploying ================================================ FILE: e2e/scenarios/fixtures/12_multi_workflow_depends_on/notify.yaml ================================================ skip_clone: true depends_on: - build steps: - name: notify image: dummy commands: - echo notifying ================================================ FILE: e2e/scenarios/fixtures/12_multi_workflow_depends_on/scenario.json ================================================ { "name": "workflows with depends_on run in order", "event": "push", "expected_status": "success", "expected_workflows": [ { "name": "build", "status": "success" }, { "name": "deploy", "status": "success" }, { "name": "notify", "status": "success" } ], "expected_steps": [ { "name": "compile", "status": "success", "exit_code": 0 }, { "name": "unit-test", "status": "success", "exit_code": 0 }, { "name": "deploy", "status": "success", "exit_code": 0 }, { "name": "notify", "status": "success", "exit_code": 0 } ] } ================================================ FILE: e2e/scenarios/fixtures/13_multi_workflow_depends_on_failure/build.yaml ================================================ skip_clone: true steps: - name: compile image: dummy environment: STEP_EXIT_CODE: '1' commands: - echo compile failed ================================================ FILE: e2e/scenarios/fixtures/13_multi_workflow_depends_on_failure/deploy.yaml ================================================ skip_clone: true depends_on: - build steps: - name: deploy image: dummy commands: - echo this should not run ================================================ FILE: e2e/scenarios/fixtures/13_multi_workflow_depends_on_failure/scenario.json ================================================ { "name": "downstream workflow skipped when dependency fails", "event": "push", "expected_status": "failure", "expected_workflows": [ { "name": "build", "status": "failure" }, { "name": "deploy", "status": "skipped" } ], "expected_steps": [ { "name": "compile", "status": "failure", "exit_code": 1 }, { "name": "deploy", "status": "killed", "exit_code": 0, "_comment": "TODO: it should be skipped not killed" } ] } ================================================ FILE: e2e/scenarios/fixtures.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package scenarios import ( "embed" "encoding/json" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) //go:embed fixtures/*.yaml fixtures/*.json fixtures/*/*.yaml fixtures/*/*.json var fixtureFS embed.FS // Scenario is the single source of truth for one integration test case. // // Single-workflow scenarios use a flat fixture pair: // // fixtures/NN_name.yaml — the pipeline YAML served by the mock forge // fixtures/NN_name.json — assertions (Scenario fields) // // Multi-workflow scenarios use a subdirectory: // // fixtures/NN_name/workflow-a.yaml // fixtures/NN_name/workflow-b.yaml // fixtures/NN_name/scenario.json — assertions; Workflows field is populated from the YAMLs type Scenario struct { // Name is a human-readable label shown in test output. Name string `json:"name"` // Event is the webhook event that triggers the pipeline (default: push). Event model.WebhookEvent `json:"event"` // ExpectedStatus is the final pipeline status we assert on. ExpectedStatus model.StatusValue `json:"expected_status"` // ExpectedSteps lists per-step assertions (matched by step name). // Steps not listed here are not checked. ExpectedSteps []ExpectedStep `json:"expected_steps"` // ExpectedWorkflows lists per-workflow assertions (matched by workflow name). // Only checked when non-empty. For single-workflow pipelines, the workflow // name is derived from the YAML filename by the step builder. ExpectedWorkflows []ExpectedWorkflow `json:"expected_workflows"` // Files is the set of workflow YAML files served by the mock forge. // Single-workflow: one entry named ".woodpecker.yaml". // Multi-workflow: one entry per file in the fixtures subdirectory, // with paths like ".woodpecker/workflow-a.yaml". // Populated by LoadScenarios — not present in the JSON. Files []*forge_types.FileMeta `json:"-"` } // ExpectedStep describes what we expect for one named step after the pipeline finishes. type ExpectedStep struct { Name string `json:"name"` Status model.StatusValue `json:"status"` ExitCode int `json:"exit_code"` } // ExpectedWorkflow describes what we expect for one named workflow after the pipeline finishes. type ExpectedWorkflow struct { Name string `json:"name"` Status model.StatusValue `json:"status"` } // LoadScenarios reads all fixture pairs and subdirectories from the embedded // fixtures/ directory and returns them sorted by filesystem order. // // Flat pairs (NN_name.yaml + NN_name.json) → single-workflow scenario. // Directories (NN_name/ with *.yaml + scenario.json) → multi-workflow scenario. func LoadScenarios(t *testing.T) []Scenario { t.Helper() entries, err := fixtureFS.ReadDir("fixtures") require.NoError(t, err, "read fixtures dir") // Index flat YAML files by stem. yamlByStem := make(map[string][]byte) jsonByStem := make(map[string][]byte) var scenarios []Scenario for _, e := range entries { name := e.Name() if e.IsDir() { // Multi-workflow scenario: load scenario.json + all *.yaml files. s := loadMultiWorkflowScenario(t, name) scenarios = append(scenarios, s) continue } data, err := fixtureFS.ReadFile(filepath.Join("fixtures", name)) require.NoError(t, err, "read fixture %s", name) stem := strings.TrimSuffix(strings.TrimSuffix(name, ".yaml"), ".json") switch filepath.Ext(name) { case ".yaml": yamlByStem[stem] = data case ".json": jsonByStem[stem] = data } } // Pair flat YAML + JSON files. for stem, jsonData := range jsonByStem { var s Scenario require.NoError(t, json.Unmarshal(jsonData, &s), "parse %s.json", stem) yamlData, ok := yamlByStem[stem] require.True(t, ok, "missing %s.yaml for %s.json", stem, stem) // Single-workflow: serve as ".woodpecker.yaml" so the config service // calls File() and gets back the YAML directly. s.Files = []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: yamlData}, } if s.Event == "" { s.Event = model.EventPush } scenarios = append(scenarios, s) } require.NotEmpty(t, scenarios, "no scenarios loaded") return scenarios } // loadMultiWorkflowScenario reads a fixtures/dirName/ subdirectory. // It expects a scenario.json and one or more *.yaml workflow files. func loadMultiWorkflowScenario(t *testing.T, dirName string) Scenario { t.Helper() dir := filepath.Join("fixtures", dirName) entries, err := fixtureFS.ReadDir(dir) require.NoError(t, err, "read multi-workflow dir %s", dir) var s Scenario var files []*forge_types.FileMeta for _, e := range entries { if e.IsDir() { continue } name := e.Name() data, err := fixtureFS.ReadFile(filepath.Join(dir, name)) require.NoError(t, err, "read %s/%s", dirName, name) switch { case name == "scenario.json": require.NoError(t, json.Unmarshal(data, &s), "parse %s/scenario.json", dirName) case strings.HasSuffix(name, ".yaml"): // Serve under .woodpecker/ so Dir() returns them. files = append(files, &forge_types.FileMeta{ Name: ".woodpecker/" + name, Data: data, }) } } require.NotEmpty(t, files, "no YAML files in multi-workflow dir %s", dirName) require.NotEmpty(t, s.Name, "scenario.json missing 'name' in %s", dirName) s.Files = forge_types.SortByName(files) if s.Event == "" { s.Event = model.EventPush } return s } ================================================ FILE: e2e/scenarios/infra_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test // Package scenarios contains end-to-end integration tests that run a real // in-process Woodpecker server (with MockForge) and a real in-process agent // (with the dummy backend). Tests trigger pipelines via server/pipeline.Create // and assert on final DB state. package scenarios import ( "os" "testing" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/e2e/setup" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) // TestMain sets global log level to warn so test output isn't buried in JSON. // Override by setting WOODPECKER_LOG_LEVEL=trace before running tests. func TestMain(m *testing.M) { level := zerolog.WarnLevel if lvl := os.Getenv("WOODPECKER_LOG_LEVEL"); lvl != "" { if l, err := zerolog.ParseLevel(lvl); err == nil { level = l } } zerolog.SetGlobalLevel(level) log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true}) os.Exit(m.Run()) } // simpleSuccessYAML is the minimal pipeline config for the smoke test. // "image: dummy" is handled by the dummy backend (requires -tags test). var simpleSuccessYAML = []byte(` steps: - name: step-one image: dummy commands: - echo hello - name: step-two image: dummy commands: - echo world `) // TestInfraSmoke verifies the full server+agent stack can start, accept a // pipeline, run it through the dummy backend, and reach StatusSuccess. // This is the "does the plumbing work at all" gate — it runs first. func TestInfraSmoke(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: simpleSuccessYAML}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) draftPipeline := &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, } createdPipeline, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, draftPipeline) require.NoError(t, err, "create pipeline") require.NotNil(t, createdPipeline) t.Logf("pipeline %d created with status=%s", createdPipeline.ID, createdPipeline.Status) finished := setup.WaitForPipeline(t, env.Store, createdPipeline.ID) assert.Equal(t, model.StatusSuccess, finished.Status, "pipeline should succeed") } ================================================ FILE: e2e/scenarios/matrix_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package scenarios import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/e2e/setup" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) // matrixPipelineYAML defines a 2×2 matrix (GO_VERSION × OS), yielding 4 // workflows. Each step echoes its matrix variables so we can confirm the // dummy backend receives the interpolated values via the step environment. var matrixPipelineYAML = []byte(` matrix: GO_VERSION: - "1.24" - "1.26" OS: - linux - windows steps: - name: build image: dummy commands: - echo "go=${GO_VERSION} os=${OS}" `) // matrixIncludePipelineYAML uses the matrix.include form to specify exact // combinations, verifying the alternative matrix syntax is also handled. var matrixIncludePipelineYAML = []byte(` matrix: include: - GO_VERSION: "1.24" OS: linux - GO_VERSION: "1.26" OS: linux - GO_VERSION: "1.26" OS: windows steps: - name: build image: dummy commands: - echo "go=${GO_VERSION} os=${OS}" `) // TestMatrixPipeline verifies that a matrix YAML expands into the correct // number of workflows, that every workflow succeeds, and that each workflow's // Environ map carries the right variable combination. func TestMatrixPipeline(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: matrixPipelineYAML}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create matrix pipeline") require.NotNil(t, created) finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusSuccess, finished.Status, "matrix pipeline should succeed") workflows, err := env.Store.WorkflowGetTree(finished) require.NoError(t, err, "get workflow tree") // 2 GO_VERSION values × 2 OS values = 4 workflows const wantWorkflows = 4 assert.Len(t, workflows, wantWorkflows, "matrix should expand to %d workflows", wantWorkflows) // Build the set of expected (GO_VERSION, OS) pairs and verify each // workflow accounts for exactly one, with no duplicates. type combo struct{ goVersion, os string } expected := map[combo]bool{ {"1.24", "linux"}: true, {"1.24", "windows"}: true, {"1.26", "linux"}: true, {"1.26", "windows"}: true, } seen := make(map[combo]bool, len(workflows)) for _, wf := range workflows { assert.Equal(t, model.StatusSuccess, wf.State, "workflow axis %d should succeed", wf.AxisID) assert.NotZero(t, wf.AxisID, "matrix workflows must have a non-zero AxisID") goVer := wf.Environ["GO_VERSION"] os := wf.Environ["OS"] c := combo{goVer, os} assert.True(t, expected[c], "unexpected matrix combination GO_VERSION=%q OS=%q", goVer, os) assert.False(t, seen[c], "duplicate matrix combination GO_VERSION=%q OS=%q", goVer, os) seen[c] = true } // Every expected combination must have been present. for c := range expected { assert.True(t, seen[c], "missing matrix combination GO_VERSION=%q OS=%q", c.goVersion, c.os) } } // TestMatrixIncludePipeline verifies the matrix.include syntax produces the // exact explicit combinations listed (3 workflows, not a full cross product). func TestMatrixIncludePipeline(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: matrixIncludePipelineYAML}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create matrix include pipeline") require.NotNil(t, created) finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusSuccess, finished.Status, "matrix include pipeline should succeed") workflows, err := env.Store.WorkflowGetTree(finished) require.NoError(t, err, "get workflow tree") // matrix.include has 3 explicit entries — no cross product. const wantWorkflows = 3 assert.Len(t, workflows, wantWorkflows, "matrix include should produce exactly %d workflows", wantWorkflows) type combo struct{ goVersion, os string } expected := map[combo]bool{ {"1.24", "linux"}: true, {"1.26", "linux"}: true, {"1.26", "windows"}: true, } seen := make(map[combo]bool, len(workflows)) for _, wf := range workflows { assert.Equal(t, model.StatusSuccess, wf.State, "workflow (axis %d) should succeed", wf.AxisID) c := combo{wf.Environ["GO_VERSION"], wf.Environ["OS"]} assert.True(t, expected[c], "unexpected combination GO_VERSION=%q OS=%q", c.goVersion, c.os) assert.False(t, seen[c], "duplicate combination GO_VERSION=%q OS=%q", c.goVersion, c.os) seen[c] = true } for c := range expected { assert.True(t, seen[c], "missing combination GO_VERSION=%q OS=%q", c.goVersion, c.os) } } // TestMatrixSingleAxis verifies a single-axis matrix (TAG: [1.7, 1.8, latest]) // — the simplest possible matrix — to ensure no edge cases in the axis // calculation code. func TestMatrixSingleAxis(t *testing.T) { yaml := []byte(` matrix: TAG: - "1.7" - "1.8" - latest steps: - name: build image: dummy commands: - echo "tag=${TAG}" `) env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: yaml}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create single-axis matrix pipeline") require.NotNil(t, created) finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusSuccess, finished.Status, "single-axis matrix pipeline should succeed") workflows, err := env.Store.WorkflowGetTree(finished) require.NoError(t, err, "get workflow tree") assert.Len(t, workflows, 3, "single-axis matrix [1.7, 1.8, latest] should produce 3 workflows") wantTags := map[string]bool{"1.7": true, "1.8": true, "latest": true} seenTags := make(map[string]bool, 3) for _, wf := range workflows { assert.Equal(t, model.StatusSuccess, wf.State, "workflow for TAG=%q should succeed", wf.Environ["TAG"]) tag := wf.Environ["TAG"] assert.True(t, wantTags[tag], "unexpected TAG value %q", tag) assert.False(t, seenTags[tag], "duplicate TAG value %q", tag) seenTags[tag] = true } } // TestMatrixNoMatrix is a regression guard: a YAML without a matrix section // must produce exactly one workflow (the existing behavior must not break). func TestMatrixNoMatrix(t *testing.T) { yaml := []byte(` steps: - name: build image: dummy commands: - echo "no matrix" `) env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: yaml}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create non-matrix pipeline") require.NotNil(t, created) finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, model.StatusSuccess, finished.Status) workflows, err := env.Store.WorkflowGetTree(finished) require.NoError(t, err, "get workflow tree") assert.Len(t, workflows, 1, "non-matrix pipeline should produce exactly 1 workflow") assert.Zero(t, workflows[0].AxisID, "non-matrix workflow should have AxisID=0") assert.Empty(t, workflows[0].Environ, "non-matrix workflow should have no Environ variables") } ================================================ FILE: e2e/scenarios/restart_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package scenarios import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/e2e/setup" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) // TestRestartPipeline verifies pipeline.Restart produces a distinct pipeline // linked to the original via Parent, with its own fresh workflow rows, and // that the original's workflows are untouched. func TestRestartPipeline(t *testing.T) { env := setup.StartServer(t.Context(), t, []*forge_types.FileMeta{ {Name: ".woodpecker.yaml", Data: simpleSuccessYAML}, }) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) // First run. original, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: model.EventPush, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create original pipeline") originalFinished := setup.WaitForPipeline(t, env.Store, original.ID) require.Equal(t, model.StatusSuccess, originalFinished.Status, "original should succeed") originalWorkflows, err := env.Store.WorkflowGetTree(originalFinished) require.NoError(t, err) require.Len(t, originalWorkflows, 1, "original should have exactly one workflow") // Restart it. restarted, err := pipeline.Restart(t.Context(), env.Store, originalFinished, env.Fixtures.Owner, env.Fixtures.Repo, nil) require.NoError(t, err, "restart pipeline") require.NotNil(t, restarted) // Parent/ID invariants. assert.NotEqual(t, originalFinished.ID, restarted.ID, "restart should have a new ID") assert.NotEqual(t, originalFinished.Number, restarted.Number, "restart should have a new number") assert.Equal(t, originalFinished.Number, restarted.Parent, "restart.Parent should point at original.Number") // The restart runs through the same start path — wait for it to finish. restartedFinished := setup.WaitForPipeline(t, env.Store, restarted.ID) assert.Equal(t, model.StatusSuccess, restartedFinished.Status, "restarted pipeline should succeed") // Restart should have its OWN workflows, not reuse the originals. restartedWorkflows, err := env.Store.WorkflowGetTree(restartedFinished) require.NoError(t, err) require.Len(t, restartedWorkflows, 1, "restart should produce its own workflow") assert.NotEqual(t, originalWorkflows[0].ID, restartedWorkflows[0].ID, "restart should insert a new workflow row, not reassign the original") assert.Equal(t, restartedFinished.ID, restartedWorkflows[0].PipelineID, "restarted workflow must be linked to the restarted pipeline") assert.Equal(t, model.StatusSuccess, restartedWorkflows[0].State) assert.Greater(t, restartedWorkflows[0].AgentID, int64(0)) // Original's workflows must remain pointing at the original pipeline. originalAfter, err := env.Store.WorkflowGetTree(originalFinished) require.NoError(t, err) require.Len(t, originalAfter, 1) assert.Equal(t, originalWorkflows[0].ID, originalAfter[0].ID, "restart must not mutate the original's workflow row") assert.Equal(t, originalFinished.ID, originalAfter[0].PipelineID, "original's workflow must still be linked to the original pipeline") } ================================================ FILE: e2e/scenarios/suite_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package scenarios import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/e2e/setup" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) // TestScenarios is the table-driven runner for all fixture-based scenarios. // Each subtest gets its own isolated server+agent environment so they cannot // interfere with each other. // // Subtests do NOT run in parallel because StartServer writes to the // server.Config package-level global — running concurrently would race. func TestScenarios(t *testing.T) { for _, sc := range LoadScenarios(t) { t.Run(sc.Name, func(t *testing.T) { runScenario(t, sc) }) } } // runScenario starts a fresh server+agent, triggers one pipeline described by // sc, waits for it to finish, then asserts the expected DB state. func runScenario(t *testing.T, sc Scenario) { t.Helper() env := setup.StartServer(t.Context(), t, sc.Files) agent := setup.StartAgent(t, env.GRPCAddr) setup.WaitForAgentRegistered(t, env.Store, agent) created, err := pipeline.Create(t.Context(), env.Store, env.Fixtures.Repo, &model.Pipeline{ Event: sc.Event, Branch: "main", Commit: "deadbeef", Ref: "refs/heads/main", Author: env.Fixtures.Owner.Login, Sender: env.Fixtures.Owner.Login, }) require.NoError(t, err, "create pipeline") require.NotNil(t, created) finished := setup.WaitForPipeline(t, env.Store, created.ID) assert.Equal(t, sc.ExpectedStatus, finished.Status, "pipeline final status") if len(sc.ExpectedSteps) == 0 { return } steps, err := env.Store.StepList(finished.ID) require.NoError(t, err, "list steps for pipeline %d", finished.ID) require.ElementsMatch(t, expStepsToName(sc.ExpectedSteps), modelStepsToName(steps), "we got different steps reported back as we expected") // Index steps by name for O(1) lookup. byName := make(map[string]*model.Step, len(steps)) for _, s := range steps { byName[s.Name] = s } for _, want := range sc.ExpectedSteps { step, ok := byName[want.Name] if !assert.Truef(t, ok, "step %q not found in pipeline %d", want.Name, finished.ID) { continue } assert.Equalf(t, want.Status, step.State, "step %q status", want.Name) assert.Equalf(t, want.ExitCode, step.ExitCode, "step %q exit code", want.Name) } if len(sc.ExpectedWorkflows) == 0 { return } workflows, err := env.Store.WorkflowGetTree(finished) require.NoError(t, err, "list workflows for pipeline %d", finished.ID) require.ElementsMatch(t, expWorkflowsToName(sc.ExpectedWorkflows), modelWorkflowsToName(workflows), "we got different workflows reported back as we expected") byWorkflowName := make(map[string]*model.Workflow, len(workflows)) for _, w := range workflows { byWorkflowName[w.Name] = w } for _, want := range sc.ExpectedWorkflows { wf, ok := byWorkflowName[want.Name] if !assert.Truef(t, ok, "workflow %q not found in pipeline %d", want.Name, finished.ID) { continue } assert.Equalf(t, want.Status, wf.State, "workflow %q status", want.Name) } } func expStepsToName(in []ExpectedStep) []string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, s.Name) } return out } func modelStepsToName(in []*model.Step) []string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, s.Name) } return out } func expWorkflowsToName(in []ExpectedWorkflow) []string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, s.Name) } return out } func modelWorkflowsToName(in []*model.Workflow) []string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, s.Name) } return out } ================================================ FILE: e2e/setup/agent.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package setup import ( "context" "testing" "time" "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" "google.golang.org/grpc/metadata" "go.woodpecker-ci.org/woodpecker/v3/agent" agent_rpc "go.woodpecker-ci.org/woodpecker/v3/agent/rpc" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/version" ) const ( AgentMaxWorkflows = 4 agentAuthRefreshEvery = 30 * time.Minute ) // AgentEnv holds the running state of one in-process test agent. // Use AgentID to assert which agent picked up a workflow. type AgentEnv struct { // AgentID is the server-assigned ID after registration. // Valid only after WaitForAgentRegistered returns. AgentID int64 // name is used for logging and as the hostname label. name string // requestedOrgID is applied to the DB record by WaitForAgentRegistered // so the server's GetServerLabels returns the right org-id filter. // model.IDNotSet (-1) means global (default). requestOrgID int64 } // AgentOption configures an agent before it registers with the server. type AgentOption func(*agentConfig) type agentConfig struct { // hostname is sent to the server as the agent's hostname metadata and label. hostname string // customLabels are merged into the agent's filter labels. // They are matched against task Labels set in pipeline YAML (labels: key: value). customLabels map[string]string // orgID pins the agent to a specific organization (-1 = global). // Org agents score higher than global agents for tasks in the same org, // so they are always preferred by the queue when available. orgID int64 } // WithHostname sets the agent's hostname label (default: "test-agent"). func WithHostname(name string) AgentOption { return func(c *agentConfig) { c.hostname = name } } // WithCustomLabels merges extra labels into the agent's filter set. // Use this to test label-based task routing, e.g.: // // setup.StartAgent(ctx, t, addr, setup.WithCustomLabels(map[string]string{"gpu": "true"})) // // The pipeline YAML must set a matching label: // // labels: // gpu: "true" func WithCustomLabels(labels map[string]string) AgentOption { return func(c *agentConfig) { for k, v := range labels { c.customLabels[k] = v } } } // WithOrgID restricts the agent to a specific organization. Org agents score // 10× higher than global agents (score 1) for tasks from the same org, so the // queue always prefers them when both are available. Pass model.IDNotSet (-1) // for a global agent (the default). func WithOrgID(id int64) AgentOption { return func(c *agentConfig) { c.orgID = id } } // StartAgent connects an in-process agent using the dummy backend to the gRPC // server at grpcAddr and returns an *AgentEnv whose AgentID is populated once // the agent has registered. Pass AgentOption values to configure labels, hostname, // or org-scoping; multiple agents can be started in the same test. func StartAgent(t *testing.T, grpcAddr string, opts ...AgentOption) *AgentEnv { t.Helper() cfg := &agentConfig{ hostname: "test-agent", customLabels: make(map[string]string), orgID: model.IDNotSet, // global by default } for _, o := range opts { o(cfg) } env := &AgentEnv{name: cfg.hostname} transport := grpc.WithTransportCredentials(insecure.NewCredentials()) keepaliveOpts := grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: defaultTimeout, Timeout: shortTimeout, }) agentCtx, agentCancel := context.WithCancelCause(t.Context()) t.Cleanup(func() { agentCancel(nil) }) authConn, err := grpc.NewClient(grpcAddr, transport, keepaliveOpts) if err != nil { t.Fatalf("StartAgent(%s): create auth gRPC connection: %v", cfg.hostname, err) } t.Cleanup(func() { authConn.Close() }) authClient := agent_rpc.NewAuthGrpcClient(authConn, TestAgentToken, -1) authInterceptor, err := agent_rpc.NewAuthInterceptor(agentCtx, authClient, agentAuthRefreshEvery) if err != nil { t.Fatalf("StartAgent(%s): authenticate with server: %v", cfg.hostname, err) } conn, err := grpc.NewClient( grpcAddr, transport, keepaliveOpts, grpc.WithUnaryInterceptor(authInterceptor.Unary()), grpc.WithStreamInterceptor(authInterceptor.Stream()), ) if err != nil { t.Fatalf("StartAgent(%s): create main gRPC connection: %v", cfg.hostname, err) } t.Cleanup(func() { conn.Close() }) client := agent_rpc.NewGrpcClient(agentCtx, conn) grpcCtx := metadata.NewOutgoingContext(agentCtx, metadata.Pairs("hostname", cfg.hostname)) backend := dummy.New() if !backend.IsAvailable(agentCtx) { t.Fatalf("StartAgent(%s): dummy backend is not available", cfg.hostname) } engInfo, err := backend.Load(agentCtx) if err != nil { t.Fatalf("StartAgent(%s): load dummy backend: %v", cfg.hostname, err) } env.AgentID, err = client.RegisterAgent(grpcCtx, rpc.AgentInfo{ Version: version.String(), Backend: backend.Name(), Platform: engInfo.Platform, Capacity: AgentMaxWorkflows, CustomLabels: cfg.customLabels, }) require.NoErrorf(t, err, "StartAgent(%s): register with server: %v", cfg.hostname, err) // If a non-global org is requested, update the agent's OrgID in the DB so // the server's GetServerLabels returns the right org-id filter (score 10). if cfg.orgID != model.IDNotSet { // The server stores agents; we patch via the store after registration. // This is done in WaitForAgentRegistered which the caller must invoke. // We stash the requested orgID so the wait helper can apply it. env.requestOrgID = cfg.orgID } t.Cleanup(func() { if err := client.UnregisterAgent(grpcCtx); err != nil { log.Warn().Err(err).Str("hostname", cfg.hostname).Msg("test agent: unregister failed (expected during teardown)") } }) // Build the filter labels the agent advertises to the queue. // org-id is handled server-side via GetServerLabels; we only set // the labels the agent explicitly provides (platform, backend, repo wildcard, // and any custom labels). filter := rpc.Filter{ Labels: map[string]string{ "hostname": cfg.hostname, "platform": engInfo.Platform, "backend": backend.Name(), "repo": "*", }, } for k, v := range cfg.customLabels { filter.Labels[k] = v } counter := &agent.State{ Polling: AgentMaxWorkflows, Metadata: make(map[string]agent.Info), } for i := range AgentMaxWorkflows { go func(slot int) { runner := agent.NewRunner(client, filter, cfg.hostname, counter, backend) log.Debug().Int("slot", slot).Str("hostname", cfg.hostname).Msg("test agent: runner started") for { if agentCtx.Err() != nil { return } if err := runner.Run(agentCtx); err != nil { if agentCtx.Err() != nil { return } log.Error().Err(err).Int("slot", slot).Str("hostname", cfg.hostname).Msg("test agent: runner error, retrying") select { case <-agentCtx.Done(): return case <-time.After(500 * time.Millisecond): } } } }(i) } return env } ================================================ FILE: e2e/setup/forge.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package setup import ( "net/http" "testing" "github.com/stretchr/testify/mock" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // newMockForge builds a MockForge that serves the given files for any // config-fetch call, no-ops status reporting, and stubs all other methods safely. // // Single-workflow (len(files)==1, name ".woodpecker.yaml"): File() returns the // raw YAML bytes; Dir() is not called but is stubbed for safety. // // Multi-workflow (len(files)>1, names ".woodpecker/foo.yaml"): File() returns // empty (causing the config service to fall through to Dir()); Dir() returns // all files. func newMockForge(t *testing.T, files []*forge_types.FileMeta) *forge_mocks.MockForge { t.Helper() m := forge_mocks.NewMockForge(t) // Identity. m.On("Name").Return("mock").Maybe() m.On("URL").Return("https://forge.example.test").Maybe() if len(files) == 1 { // Single-workflow: config service calls File(".woodpecker.yaml"). m.On("File", mock.Anything, mock.Anything, mock.Anything, mock.Anything, ".woodpecker.yaml", ).Return(files[0].Data, nil).Maybe() m.On("Dir", mock.Anything, mock.Anything, mock.Anything, mock.Anything, ".woodpecker", ).Return(files, nil).Maybe() } else { // Multi-workflow: config service calls Dir(".woodpecker"). // File() must return empty so the service falls through to Dir(). m.On("File", mock.Anything, mock.Anything, mock.Anything, mock.Anything, ".woodpecker.yaml", ).Return([]byte(nil), nil).Maybe() m.On("Dir", mock.Anything, mock.Anything, mock.Anything, mock.Anything, ".woodpecker", ).Return(files, nil).Maybe() } // Status reporting back to forge — no-op. m.On("Status", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, ).Return(nil).Maybe() // Netrc for clone steps. m.On("Netrc", mock.Anything, mock.Anything, ).Return(&model.Netrc{}, nil).Maybe() return m } // compile-time import guard. var _ *http.Request ================================================ FILE: e2e/setup/server.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package setup import ( "context" "net" "sync" "testing" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "google.golang.org/grpc" "google.golang.org/grpc/keepalive" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/cache" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" "go.woodpecker-ci.org/woodpecker/v3/server/queue" server_rpc "go.woodpecker-ci.org/woodpecker/v3/server/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" "go.woodpecker-ci.org/woodpecker/v3/server/services" "go.woodpecker-ci.org/woodpecker/v3/server/services/permissions" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) const ( // TestAgentToken is the shared secret used between the in-process server // and agent. Hard-coded for tests — not a real secret. TestAgentToken = "test-agent-secret-for-integration-tests" // TestJWTSecret is used for signing gRPC auth JWTs. TestJWTSecret = "test-jwt-secret-for-integration-tests" // TestForgeType is the forge type the mock pretends to bee. TestForgeType = model.ForgeTypeGitea ) var configLock = sync.Mutex{} // ServerEnv holds all the pieces of a running test server environment. type ServerEnv struct { GRPCAddr string Store store.Store Queue queue.Queue Fixtures *Fixtures Forge *forge_mocks.MockForge Manager services.Manager } // StartServer wires up the full in-process server stack: // - in-memory sqlite store (fully migrated) with seeded fixtures // - in-memory queue, pubsub, and logging // - MockForge that serves the provided workflow files // - gRPC server on a random TCP port // // files must contain at least one entry. Single-workflow scenarios pass one // file named ".woodpecker.yaml"; multi-workflow scenarios pass multiple files // named ".woodpecker/foo.yaml" etc. The repo's Config path is set accordingly. // // All resources are cleaned up via t.Cleanup. func StartServer(ctx context.Context, t *testing.T, files []*forge_types.FileMeta) *ServerEnv { t.Helper() configLock.Lock() defer configLock.Unlock() memStore := newStore(ctx, t) fixtures := seedFixtures(t, memStore) mockForge := newMockForge(t, files) mgr, err := newTestManager(memStore, mockForge) require.NoError(t, err, "create services manager") memQueue, err := queue.New(ctx, queue.Config{Backend: queue.TypeMemory}) require.NoError(t, err, "create queue") // Save and restore server.Config around the test. server.Config is a // package-level global read by server/pipeline and server/rpc. Tests run // sequentially within a package, but we still need to clean up so the next // subtest starts from a known-zero state rather than the previous test's values. orig := server.Config t.Cleanup(func() { configLock.Lock() defer configLock.Unlock() server.Config = orig }) server.Config.Services.Logs = logging.New() server.Config.Services.Scheduler = scheduler.NewScheduler(memQueue, memory.New()) server.Config.Services.Membership = cache.NewMembershipService(memStore) server.Config.Services.Manager = mgr server.Config.Services.LogStore = memStore server.Config.Server.AgentToken = TestAgentToken server.Config.Server.Host = "http://localhost" server.Config.Server.JWTSecret = TestJWTSecret server.Config.Pipeline.DefaultClonePlugin = "docker.io/woodpeckerci/plugin-git:latest" server.Config.Pipeline.TrustedClonePlugins = []string{"docker.io/woodpeckerci/plugin-git:latest"} server.Config.Pipeline.DefaultApprovalMode = model.RequireApprovalNone server.Config.Pipeline.DefaultTimeout = 60 server.Config.Pipeline.MaxTimeout = 60 server.Config.Permissions.Open = true server.Config.Permissions.Admins = permissions.NewAdmins([]string{}) server.Config.Permissions.Orgs = permissions.NewOrgs([]string{}) server.Config.Permissions.OwnersAllowlist = permissions.NewOwnersAllowlist([]string{}) grpcAddr := startGRPCServer(ctx, t, memStore) return &ServerEnv{ GRPCAddr: grpcAddr, Store: memStore, Queue: memQueue, Fixtures: fixtures, Forge: mockForge, Manager: mgr, } } // newTestManager builds a services.Manager whose SetupForge always returns // the provided MockForge, bypassing real forge instantiation. func newTestManager(s store.Store, mockForge *forge_mocks.MockForge) (services.Manager, error) { cmd := &cli.Command{ Flags: []cli.Flag{ // Config fetch tuning. &cli.DurationFlag{Name: "forge-timeout", Value: defaultTimeout}, &cli.UintFlag{Name: "forge-retry", Value: defaultRetry}, &cli.StringSliceFlag{Name: "environment"}, // Forge flags — gitea=true satisfies setupForgeService's type switch. &cli.BoolFlag{Name: string(TestForgeType), Value: true}, &cli.StringFlag{Name: "forge-url", Value: "https://forge.example.test"}, }, } setupForge := services.SetupForge(func(*model.Forge) (forge.Forge, error) { return mockForge, nil }) return services.NewManager(cmd, s, setupForge) } // startGRPCServer binds to a random TCP port, registers Woodpecker's gRPC // services, and starts serving. Shutdown happens via t.Cleanup. func startGRPCServer(ctx context.Context, t *testing.T, s store.Store) string { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err, "listen on random port for gRPC") addr := lis.Addr().String() jwtManager := server_rpc.NewJWTManager(TestJWTSecret) authorizer := server_rpc.NewAuthorizer(jwtManager) grpcServer := grpc.NewServer( grpc.StreamInterceptor(authorizer.StreamInterceptor), grpc.UnaryInterceptor(authorizer.UnaryInterceptor), grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: shortTimeout, }), ) proto.RegisterWoodpeckerServer(grpcServer, server_rpc.NewTestWoodpeckerServer( server.Config.Services.Scheduler, server.Config.Services.Logs, s, prometheus.NewRegistry(), )) proto.RegisterWoodpeckerAuthServer(grpcServer, server_rpc.NewWoodpeckerAuthServer( jwtManager, TestAgentToken, s, )) stopped := make(chan struct{}) grpcCtx, grpcCancel := context.WithCancelCause(ctx) go func() { <-grpcCtx.Done() grpcServer.GracefulStop() close(stopped) }() go func() { if err := grpcServer.Serve(lis); err != nil { grpcCancel(err) } }() t.Cleanup(func() { grpcCancel(nil) <-stopped }) return addr } ================================================ FILE: e2e/setup/store.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package setup import ( "context" "testing" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/datastore" ) // Fixtures holds the pre-seeded database records shared across all tests. type Fixtures struct { Forge *model.Forge Owner *model.User Repo *model.Repo } // newStore creates a fully-migrated in-memory sqlite store. func newStore(ctx context.Context, t *testing.T) store.Store { t.Helper() s, err := datastore.NewEngine(&store.Opts{ Driver: "sqlite3", Config: ":memory:", // MaxOpenConns=1 and MaxIdleConns=1 are required for in-memory sqlite: // without them the pool drops idle connections, destroying the in-memory // schema between calls and breaking migrations. XORM: store.XORM{ MaxOpenConns: 1, MaxIdleConns: 1, }, }) require.NoError(t, err, "create in-memory store") require.NoError(t, s.Ping(), "ping store") require.NoError(t, s.Migrate(ctx, true), "migrate store") t.Cleanup(func() { _ = s.Close() }) return s } // seedFixtures creates the minimal set of DB records every test needs: // one Forge, one owner User, one Repo linked to both. func seedFixtures(t *testing.T, s store.Store) *Fixtures { t.Helper() forge := &model.Forge{ Type: TestForgeType, URL: "https://forge.example.test", } require.NoError(t, s.ForgeCreate(forge), "seed forge") owner := &model.User{ ForgeID: forge.ID, ForgeRemoteID: "1", Login: "test-owner", Email: "owner@example.test", } require.NoError(t, s.CreateUser(owner), "seed user") repo := &model.Repo{ ForgeID: forge.ID, ForgeRemoteID: "1", UserID: owner.ID, FullName: "test-owner/test-repo", Owner: "test-owner", Name: "test-repo", Clone: "https://forge.example.test/test-owner/test-repo.git", Branch: "main", IsActive: true, AllowPull: true, } require.NoError(t, s.CreateRepo(repo), "seed repo") return &Fixtures{ Forge: forge, Owner: owner, Repo: repo, } } ================================================ FILE: e2e/setup/wait.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package setup import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/queue" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) const ( defaultTimeout = 30 * time.Second defaultRetry = 3 shortTimeout = 10 * time.Second defaultInterval = 100 * time.Millisecond ) // isTerminal returns true if the status is a final (non-running) state. func isTerminal(s model.StatusValue) bool { switch s { case model.StatusSuccess, model.StatusFailure, model.StatusKilled, model.StatusError, model.StatusDeclined, model.StatusCanceled: return true } return false } // WaitForPipeline polls the store until the pipeline with the given ID reaches // a terminal status, then returns it. Fails the test if timeout is exceeded. func WaitForPipeline(t *testing.T, s store.Store, pipelineID int64) *model.Pipeline { t.Helper() return WaitForPipelineStatus(t, s, pipelineID, "", defaultTimeout) } // WaitForPipelineStatus polls until the pipeline reaches wantStatus (or any // terminal status if wantStatus is empty). Fails the test on timeout. func WaitForPipelineStatus(t *testing.T, s store.Store, pipelineID int64, wantStatus model.StatusValue, timeout time.Duration) *model.Pipeline { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { p, err := s.GetPipeline(pipelineID) require.NoError(t, err, "get pipeline %d", pipelineID) if wantStatus != "" { if p.Status == wantStatus { return p } } else if isTerminal(p.Status) { return p } time.Sleep(defaultInterval) } p, _ := s.GetPipeline(pipelineID) t.Fatalf("timeout waiting for pipeline %d: last status=%q (want %q)", pipelineID, p.Status, wantStatus) return nil } // WaitForAgentRegistered polls until all provided agents appear in the store // (by AgentID), then applies any deferred DB patches (e.g. OrgID). // Pass every *AgentEnv returned by StartAgent before triggering pipelines. func WaitForAgentRegistered(t *testing.T, s store.Store, agents ...*AgentEnv) { t.Helper() deadline := time.Now().Add(shortTimeout) for time.Now().Before(deadline) { allFound := true for _, env := range agents { if env.AgentID == 0 { allFound = false break } if _, err := s.AgentFind(env.AgentID); err != nil { allFound = false break } } if allFound { // Apply any deferred OrgID patches. for _, env := range agents { if env.requestOrgID == model.IDNotSet { continue } agent, err := s.AgentFind(env.AgentID) require.NoError(t, err, "find agent %d to patch OrgID", env.AgentID) agent.OrgID = env.requestOrgID require.NoError(t, s.AgentUpdate(agent), "patch OrgID on agent %d", env.AgentID) } return } time.Sleep(defaultInterval) } t.Fatal("timeout: not all agents registered with the server") } // WaitForStep polls the store until a named step in the given pipeline reaches // a terminal status. It returns the final step state. Fails the test on timeout. func WaitForStep(t *testing.T, s store.Store, pipeline *model.Pipeline, stepName string) *model.Step { t.Helper() return WaitForStepStatus(t, s, pipeline, stepName, "", defaultTimeout) } // WaitForStepStatus polls until a named step reaches wantState (or any terminal // state when wantState is empty). This is useful after a pipeline.Cancel() call // where the agent sends its final step status asynchronously via gRPC Done(), // independently of the pipeline itself reaching a terminal status. func WaitForStepStatus(t *testing.T, s store.Store, pipeline *model.Pipeline, stepName string, wantState model.StatusValue, timeout time.Duration) *model.Step { t.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { steps, err := s.StepList(pipeline.ID) require.NoError(t, err, "list steps for pipeline %d", pipeline.ID) for _, step := range steps { if step.Name != stepName { continue } if wantState != "" { if step.State == wantState { return step } } else if isTerminal(step.State) { return step } } time.Sleep(defaultInterval) } steps, _ := s.StepList(pipeline.ID) var lastState model.StatusValue for _, step := range steps { if step.Name == stepName { lastState = step.State break } } if wantState != "" { t.Fatalf("timeout waiting for step %q in pipeline %d to reach state %q: last state=%q", stepName, pipeline.ID, wantState, lastState) } else { t.Fatalf("timeout waiting for step %q in pipeline %d to reach terminal state: last state=%q", stepName, pipeline.ID, lastState) } return nil } // AssertWorkflowRanOnAgent asserts that the named workflow in the finished // pipeline was executed by the given agent. Use this to verify label-based // routing and org-agent preference. func AssertWorkflowRanOnAgent(t *testing.T, s store.Store, pipeline *model.Pipeline, workflowName string, agent *AgentEnv) { t.Helper() workflows, err := s.WorkflowGetTree(pipeline) require.NoError(t, err, "get workflow tree for pipeline %d", pipeline.ID) for _, wf := range workflows { if wf.Name == workflowName { assert.Equalf(t, agent.AgentID, wf.AgentID, "workflow %q should have run on agent %d (%s) but ran on agent %d", workflowName, agent.AgentID, agent.name, wf.AgentID) return } } t.Errorf("workflow %q not found in pipeline %d", workflowName, pipeline.ID) } // WaitForWorkersReady polls the queue until at least minWorkers worker slots // are active (i.e. agents have connected and are blocking on Poll). Call this // after WaitForAgentRegistered and before pipeline.Create in tests that rely // on specific routing: the org-id label is read from the DB at Poll time, so // the org-agent must have started its poll loop *after* its OrgID has been // patched — otherwise the global agent can win the race and steal the task // before the org-agent advertises its exact org-id label. func WaitForWorkersReady(t *testing.T, q queue.Queue, minWorkers int) { t.Helper() deadline := time.Now().Add(shortTimeout) for time.Now().Before(deadline) { info := q.Info(context.Background()) if info.Stats.Workers >= minWorkers { return } time.Sleep(defaultInterval) } info := q.Info(context.Background()) t.Fatalf("timeout waiting for %d workers to be ready in queue: got %d", minWorkers, info.Stats.Workers) } // WaitForStepRunning polls the store until a named step in the pipeline with // the given ID reaches StatusRunning. This is used before triggering a cancel // so we know the dummy backend's sleepWithContext is genuinely blocking — if // we cancel before the step is running, the step may finish with StatusSuccess // before the cancel context propagates to WaitStep. func WaitForStepRunning(t *testing.T, s store.Store, pipelineID int64, stepName string) { t.Helper() deadline := time.Now().Add(shortTimeout) for time.Now().Before(deadline) { p, err := s.GetPipeline(pipelineID) require.NoError(t, err, "get pipeline %d", pipelineID) steps, err := s.StepList(p.ID) require.NoError(t, err, "list steps for pipeline %d", pipelineID) for _, step := range steps { if step.Name == stepName && step.State == model.StatusRunning { return } } time.Sleep(defaultInterval) } t.Fatalf("timeout waiting for step %q in pipeline %d to reach StatusRunning", stepName, pipelineID) } ================================================ FILE: flake.nix ================================================ { inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=master"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem ( system: let pkgs = nixpkgs.legacyPackages.${system}; in { devShells.default = with pkgs; let go = go_1_26; in pkgs.mkShell { buildInputs = [ # generic gnumake gnutar gzip zip tree # frontend nodejs_24 pnpm typescript typescript-language-server # backend go glibc.static gofumpt golangci-lint go-mockery protobuf sqlite go-swag # for generate-openapi addlicense protoc-gen-go protoc-gen-go-grpc gcc # docs graphviz ]; CFLAGS = "-I${pkgs.glibc.dev}/include"; LDFLAGS = "-L${pkgs.glibc}/lib"; GO = "${go}/bin/go"; GOROOT = "${go}/share/go"; }; } ); } ================================================ FILE: go.mod ================================================ module go.woodpecker-ci.org/woodpecker/v3 go 1.26.0 require ( al.essio.dev/pkg/shellescape v1.6.0 charm.land/huh/v2 v2.0.3 code.gitea.io/sdk/gitea v0.25.0 codeberg.org/6543/go-yaml2json v1.0.0 codeberg.org/6543/xyaml v1.1.0 codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0 github.com/6543/logfile-open v1.2.1 github.com/adrg/xdg v0.5.3 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/containerd/errdefs v1.0.0 github.com/distribution/reference v0.6.0 github.com/docker/cli v29.4.3+incompatible github.com/docker/go-connections v0.7.0 github.com/docker/go-units v0.5.0 github.com/drone/envsubst v1.0.3 github.com/expr-lang/expr v1.17.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gdgvda/cron v0.7.0 github.com/getkin/kin-openapi v0.138.0 github.com/gin-gonic/gin v1.12.0 github.com/gitsight/go-vcsurl v1.0.1 github.com/go-sql-driver/mysql v1.10.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-github/v86 v86.0.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.8.0 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/joho/godotenv v1.5.1 github.com/kinbiko/jsonassert v1.2.0 github.com/lib/pq v1.12.3 github.com/mattn/go-sqlite3 v1.14.44 github.com/migueleliasweb/go-github-mock v1.5.0 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 github.com/moby/term v0.5.2 github.com/muesli/termenv v0.16.0 github.com/neticdk/go-bitbucket v1.0.5 github.com/oklog/ulid/v2 v2.1.1 github.com/prometheus/client_golang v1.23.2 github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 github.com/tink-crypto/tink-go/v2 v2.6.0 github.com/urfave/cli-docs/v3 v3.1.1-0.20251022123016-72b87d11c482 github.com/urfave/cli/v3 v3.8.0 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yaronf/httpsign v0.5.1 github.com/zalando/go-keyring v0.2.8 gitlab.com/gitlab-org/api/client-go/v2 v2.25.0 go.uber.org/multierr v1.11.0 golang.org/x/crypto v0.51.0 golang.org/x/image v0.40.0 golang.org/x/net v0.54.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.36.0 k8s.io/apimachinery v0.36.0 k8s.io/client-go v0.36.0 sigs.k8s.io/yaml v1.6.0 src.techknowlogick.com/xormigrate v1.7.1 xorm.io/builder v0.3.13 xorm.io/xorm v1.3.11 ) require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/lipgloss/v2 v2.0.1 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/42wim/httpsig v1.2.4 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/docker/docker-credential-helpers v0.8.0 // indirect github.com/dunglas/httpsfv v1.0.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/errors v0.22.6 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/spec v0.22.3 // indirect github.com/go-openapi/strfmt v0.25.0 // indirect github.com/go-openapi/swag v0.25.4 // indirect github.com/go-openapi/swag/cmdutils v0.25.4 // indirect github.com/go-openapi/swag/conv v0.25.4 // indirect github.com/go-openapi/swag/fileutils v0.25.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-openapi/swag/jsonutils v0.25.4 // indirect github.com/go-openapi/swag/loading v0.25.4 // indirect github.com/go-openapi/swag/mangling v0.25.4 // indirect github.com/go-openapi/swag/netutils v0.25.4 // indirect github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-github/v73 v73.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/jwx/v2 v2.1.2 // indirect github.com/lestrrat-go/jwx/v3 v3.0.12 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oasdiff/yaml v0.0.9 // indirect github.com/oasdiff/yaml3 v0.0.12 // indirect github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/onsi/ginkgo v1.16.4 // indirect github.com/onsi/gomega v1.10.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/syndtr/goleveldb v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/urfave/cli/v2 v2.25.3 // indirect github.com/valyala/fastjson v1.6.4 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) ================================================ FILE: go.sum ================================================ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= code.gitea.io/sdk/gitea v0.25.0 h1:wSJlL0Qv+ODY2OdF0L7fwt86wgf1C/0g3xIXZ6eC5zI= code.gitea.io/sdk/gitea v0.25.0/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg= codeberg.org/6543/go-yaml2json v1.0.0 h1:heGqo9VEi7gY2yNqjj7X4ADs5nzlFIbGsJtgYDLrnig= codeberg.org/6543/go-yaml2json v1.0.0/go.mod h1:mz61q14LWF4ZABrgMEDMmk3t9dPi6zgR1uBh2VKV2RQ= codeberg.org/6543/xyaml v1.1.0 h1:0PWTy8OUqshshjrrnAXFWXSPUEa8R49DIh2ah07SxFc= codeberg.org/6543/xyaml v1.1.0/go.mod h1:jI7afXLZUxeL4rNNsG1SlHh78L+gma9lK1bIebyFZwA= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0 h1:s2fK+FBwvcYsmKDjNhmoe7B8q9zsgs0UrSlYe9r4XjM= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3 v3.0.0/go.mod h1:Is2jTpS1dizeXm4skQv/ES3QVqnzcNhn2GzZXpiw9f8= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/6543/logfile-open v1.2.1 h1:az+TtNHclTAKaHfFCTSbuduMllANox1gM9qLQr7LV5I= github.com/6543/logfile-open v1.2.1/go.mod h1:ZoEy7pW2mexmQxiZIqPCeh8vUxVuiHYXmSZNbvEb51g= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+8Y+8shw= github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.8.0 h1:YQFtbBQb4VrpoPxhFuzEBPQ9E16qz5SpHLS+uswaCp8= github.com/docker/docker-credential-helpers v0.8.0/go.mod h1:UGFXcuoQ5TxPiB54nHOZ32AWRqQdECoh/Mg0AlEYb40= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdgvda/cron v0.7.0 h1:LFPZUTbCb5ZpzYxavbQDDbjd6nwTwkiNUWyulOdlY2I= github.com/gdgvda/cron v0.7.0/go.mod h1:caBF+mzTZGtQqFE05T1m6u9OmCASY3EK51XAICf3wio= github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gitsight/go-vcsurl v1.0.1 h1:wkijKsbVg9R2IBP97U7wOANeIW9WJJKkBwS9XqllzWo= github.com/gitsight/go-vcsurl v1.0.1/go.mod h1:qRFdKDa/0Lh9MT0xE+qQBYZ/01+mY1H40rZUHR24X9U= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/errors v0.22.6 h1:eDxcf89O8odEnohIXwEjY1IB4ph5vmbUsBMsFNwXWPo= github.com/go-openapi/errors v0.22.6/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc= github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-github/v86 v86.0.0 h1:S/6aANJhwRm8EQmGKVML3j41yq0h2BsTP8FnDkO7kcA= github.com/google/go-github/v86 v86.0.0/go.mod h1:zKv1l4SwDXNFMGByi2FWkq71KwSXqj/eQRZuqtmcot8= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/graph-gophers/graphql-go v1.9.0 h1:yu0ucKHLc5qGpRwLYKIWtr9bOoxovkWasuBrPQwlHls= github.com/graph-gophers/graphql-go v1.9.0/go.mod h1:23olKZ7duEvHlF/2ELEoSZaY1aNPfShjP782SOoNTyM= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs= github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= github.com/jackc/pgconn v1.14.0/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E= github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= github.com/jackc/pgtype v1.14.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= github.com/jackc/pgx/v4 v4.18.0/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE= github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kinbiko/jsonassert v1.2.0 h1:+/JthIVXdIrThrOtSN9ry0mNtWKXMWuvxR0nU7gQ+tI= github.com/kinbiko/jsonassert v1.2.0/go.mod h1:pCc3uudOt+lVAbkji9O0uw8MSVt4s+1ZJ0y8Ux2F1Og= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/jwx/v2 v2.1.2 h1:6poete4MPsO8+LAEVhpdrNI4Xp2xdiafgl2RD89moBc= github.com/lestrrat-go/jwx/v2 v2.1.2/go.mod h1:pO+Gz9whn7MPdbsqSJzG8TlEpMZCwQDXnFJ+zsUVh8Y= github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/migueleliasweb/go-github-mock v1.5.0 h1:dIr6vgVz8QY9sDiDopWxk6pDw4d7K/xIcCk/NQe4ajM= github.com/migueleliasweb/go-github-mock v1.5.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neticdk/go-bitbucket v1.0.5 h1:H/++KM+O0EXVDbgMadbAsKwqjLKi0vDwa+vGU9lMChg= github.com/neticdk/go-bitbucket v1.0.5/go.mod h1:4ZMxzmr5hi/EoLdydtR7h4dd4DpqK8tbnVLbAkscRc8= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli-docs/v3 v3.1.1-0.20251022123016-72b87d11c482 h1:xlSy8R55vuHbUM1c2mwfQ5MFeVTnm59BwQeWibPUD5A= github.com/urfave/cli-docs/v3 v3.1.1-0.20251022123016-72b87d11c482/go.mod h1:cjSVza4yCaqQet06zO6QhYqXQYjGRqbUj8zok6mHDRU= github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY= github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yaronf/httpsign v0.5.1 h1:hYFnX4ND+tgsMAho95b65uj36ho4ND+U2fXvIbAoxl8= github.com/yaronf/httpsign v0.5.1/go.mod h1:euOXi3++HLtx5YlsJEWcIzF3ztK4TL2M2F0Wg3KL+V0= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= gitlab.com/gitlab-org/api/client-go/v2 v2.25.0 h1:ATTBB0Iiup5SRox2IPNSkkrGy/Any7FWBL1BOpZrpCU= gitlab.com/gitlab-org/api/client-go/v2 v2.25.0/go.mod h1:OSJITkIrT0UuA3JCucEK9UEGcC1PWBkQg5WW6W4nWuo= go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210610132358-84b48f89b13b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI= modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0= modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA= modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0= modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0= modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE= modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= src.techknowlogick.com/xormigrate v1.7.1 h1:RKGLLUAqJ+zO8iZ7eOc7oLH7f0cs2gfXSZSvBRBHnlY= src.techknowlogick.com/xormigrate v1.7.1/go.mod h1:YGNBdj8prENlySwIKmfoEXp7ILGjAltyKFXD0qLgD7U= xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/xorm v1.3.3/go.mod h1:qFJGFoVYbbIdnz2vaL5OxSQ2raleMpyRRalnq3n9OJo= xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI= xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= ================================================ FILE: nfpm/agent.yaml ================================================ name: woodpecker-agent arch: amd64 platform: linux version: ${VERSION_NUMBER} description: Woodpecker Agent homepage: https://woodpecker-ci.org/ license: Apache 2.0 maintainer: Woodpecker Authors section: daemon/system scripts: preinstall: ./nfpm/woodpecker-system-user.preinstall.sh contents: - src: ./dist/agent/linux_amd64/woodpecker-agent dst: /usr/local/bin/woodpecker-agent - src: ./nfpm/woodpecker-agent.service dst: /usr/local/lib/systemd/system/woodpecker-agent.service - src: ./nfpm/woodpecker-agent.env.example dst: /etc/woodpecker/woodpecker-agent.env.example - dst: /var/lib/woodpecker/ type: dir file_info: owner: woodpecker group: woodpecker mode: 0750 ================================================ FILE: nfpm/cli.yaml ================================================ name: woodpecker-cli arch: amd64 platform: linux version: ${VERSION_NUMBER} description: Woodpecker CLI homepage: https://woodpecker-ci.org/ license: Apache 2.0 maintainer: Woodpecker Authors section: utils contents: - src: ./dist/cli/linux_amd64/woodpecker-cli dst: /usr/local/bin/woodpecker-cli ================================================ FILE: nfpm/server.yaml ================================================ name: woodpecker-server arch: amd64 platform: linux version: ${VERSION_NUMBER} description: Woodpecker Server homepage: https://woodpecker-ci.org/ license: Apache 2.0 maintainer: Woodpecker Authors section: daemon/system scripts: preinstall: ./nfpm/woodpecker-system-user.preinstall.sh contents: - src: ./dist/server/linux_amd64/woodpecker-server dst: /usr/local/bin/woodpecker-server - src: ./nfpm/woodpecker-server.service dst: /usr/local/lib/systemd/system/woodpecker-server.service - src: ./nfpm/woodpecker-server.env.example dst: /etc/woodpecker/woodpecker-server.env.example - dst: /var/lib/woodpecker/ type: dir file_info: owner: woodpecker group: woodpecker mode: 0750 ================================================ FILE: nfpm/woodpecker-agent.env.example ================================================ # Example for a woodpecker-agent.env file # Check the documentation for the agent: # https://woodpecker-ci.org/docs/administration/configuration/agent # Add all required environment variables for your setup in the form of VARIABLE=value VARIABLE=value ================================================ FILE: nfpm/woodpecker-agent.service ================================================ [Unit] Description=WoodpeckerCI agent Documentation=https://woodpecker-ci.org/docs/administration/configuration/agent Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-agent.env ConditionPathExists=/etc/woodpecker/woodpecker-agent.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-agent.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-agent WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ================================================ FILE: nfpm/woodpecker-server.env.example ================================================ # Example for a woodpecker-server.env file # Check the documentation for the server: # https://woodpecker-ci.org/docs/administration/configuration/server # Add all required environment variables for your setup in the form of VARIABLE=value VARIABLE=value ================================================ FILE: nfpm/woodpecker-server.service ================================================ [Unit] Description=WoodpeckerCI server Documentation=https://woodpecker-ci.org/docs/administration/configuration/server Requires=network.target After=network.target ConditionFileNotEmpty=/etc/woodpecker/woodpecker-server.env ConditionPathExists=/etc/woodpecker/woodpecker-server.env [Service] Type=simple EnvironmentFile=/etc/woodpecker/woodpecker-server.env User=woodpecker Group=woodpecker ExecStart=/usr/local/bin/woodpecker-server WorkingDirectory=/var/lib/woodpecker/ StateDirectory=woodpecker [Install] WantedBy=multi-user.target ================================================ FILE: nfpm/woodpecker-system-user.preinstall.sh ================================================ #!/bin/sh set -e # Create woodpecker group if it doesn't exist if ! getent group woodpecker > /dev/null 2>&1; then groupadd --system woodpecker fi # Create woodpecker user if it doesn't exist if ! getent passwd woodpecker > /dev/null 2>&1; then useradd \ --system \ --gid woodpecker \ --no-create-home \ --home-dir /var/lib/woodpecker \ --shell /sbin/nologin \ woodpecker fi ================================================ FILE: pipeline/backend/backend.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package backend import ( "context" "fmt" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func FindBackend(ctx context.Context, backends []types.Backend, backendName string) (types.Backend, error) { if backendName == "auto-detect" { for _, engine := range backends { if engine.IsAvailable(ctx) { return engine, nil } } return nil, fmt.Errorf("can't detect an available backend engine") } for _, engine := range backends { if engine.Name() == backendName { return engine, nil } } return nil, fmt.Errorf("backend engine '%s' not found", backendName) } ================================================ FILE: pipeline/backend/common/script.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "encoding/base64" ) func GenerateContainerConf(commands []string, osType, workDir string) (env map[string]string, entry []string) { env = make(map[string]string) if osType == "windows" { env["CI_SCRIPT"] = base64.StdEncoding.EncodeToString([]byte(generateScriptWindows(commands, workDir))) env["SHELL"] = "powershell.exe" // cspell:disable-next-line entry = []string{"powershell", "-noprofile", "-noninteractive", "-command", "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Env:CI_SCRIPT)) | iex"} } else { env["CI_SCRIPT"] = base64.StdEncoding.EncodeToString([]byte(generateScriptPosix(commands, workDir))) env["SHELL"] = "/bin/sh" entry = []string{"/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"} } return env, entry } ================================================ FILE: pipeline/backend/common/script_posix.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "bytes" "fmt" "text/template" "al.essio.dev/pkg/shellescape" ) // generateScriptPosix is a helper function that generates a step script // for a linux container using the given. func generateScriptPosix(commands []string, workDir string) string { var buf bytes.Buffer if err := setupScriptTmpl.Execute(&buf, map[string]string{ "WorkDir": workDir, }); err != nil { // should never happen but well we have an error to trance return fmt.Sprintf("echo 'failed to generate posix script from commands: %s'; exit 1", err.Error()) } for _, command := range commands { fmt.Fprintf(&buf, traceScript, shellescape.Quote(command), command, ) } return buf.String() } // setupScriptProto is a helper script this is added to the step script to ensure // a minimum set of environment variables are set correctly. const setupScriptProto = ` if [ -n "$CI_NETRC_MACHINE" ]; then cat < $HOME/.netrc machine $CI_NETRC_MACHINE login $CI_NETRC_USERNAME password $CI_NETRC_PASSWORD EOF chmod 0600 $HOME/.netrc fi unset CI_NETRC_USERNAME unset CI_NETRC_PASSWORD unset CI_SCRIPT mkdir -p "{{.WorkDir}}" cd "{{.WorkDir}}" ` var setupScriptTmpl, _ = template.New("").Parse(setupScriptProto) // traceScript is a helper script that is added to the step script // to trace a command. const traceScript = ` echo + %s %s ` ================================================ FILE: pipeline/backend/common/script_posix_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "testing" "text/template" "github.com/stretchr/testify/assert" ) func TestGenerateScriptPosix(t *testing.T) { testdata := []struct { from []string want string }{ { from: []string{"echo ${PATH}", "go build", "go test"}, want: ` if [ -n "$CI_NETRC_MACHINE" ]; then cat < $HOME/.netrc machine $CI_NETRC_MACHINE login $CI_NETRC_USERNAME password $CI_NETRC_PASSWORD EOF chmod 0600 $HOME/.netrc fi unset CI_NETRC_USERNAME unset CI_NETRC_PASSWORD unset CI_SCRIPT mkdir -p "/woodpecker/some" cd "/woodpecker/some" echo + 'echo ${PATH}' echo ${PATH} echo + 'go build' go build echo + 'go test' go test `, }, } for _, test := range testdata { script := generateScriptPosix(test.from, "/woodpecker/some") assert.EqualValues(t, test.want, script, "Want encoded script for %s", test.from) } } func TestSetupScriptProtoParse(t *testing.T) { // just ensure that we have a working `setupScriptTmpl` on runntime _, err := template.New("").Parse(setupScriptProto) assert.NoError(t, err) } ================================================ FILE: pipeline/backend/common/script_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "testing" "github.com/stretchr/testify/assert" ) const ( windowsScriptBase64 = "CiRFcnJvckFjdGlvblByZWZlcmVuY2UgPSAnU3RvcCc7CmlmICgtbm90IChUZXN0LVBhdGggIi93b29kcGVja2VyL3NvbWUiKSkgeyBOZXctSXRlbSAtUGF0aCAiL3dvb2RwZWNrZXIvc29tZSIgLUl0ZW1UeXBlIERpcmVjdG9yeSAtRm9yY2UgfTsKaWYgKC1ub3QgW0Vudmlyb25tZW50XTo6R2V0RW52aXJvbm1lbnRWYXJpYWJsZSgnSE9NRScpKSB7IFtFbnZpcm9ubWVudF06OlNldEVudmlyb25tZW50VmFyaWFibGUoJ0hPTUUnLCAnYzpccm9vdCcpIH07CmlmICgtbm90IChUZXN0LVBhdGggIiRlbnY6SE9NRSIpKSB7IE5ldy1JdGVtIC1QYXRoICIkZW52OkhPTUUiIC1JdGVtVHlwZSBEaXJlY3RvcnkgLUZvcmNlIH07CmlmICgkRW52OkNJX05FVFJDX01BQ0hJTkUpIHsKJG5ldHJjPVtzdHJpbmddOjpGb3JtYXQoInswfVxfbmV0cmMiLCRFbnY6SE9NRSk7CiJtYWNoaW5lICRFbnY6Q0lfTkVUUkNfTUFDSElORSIgPj4gJG5ldHJjOwoibG9naW4gJEVudjpDSV9ORVRSQ19VU0VSTkFNRSIgPj4gJG5ldHJjOwoicGFzc3dvcmQgJEVudjpDSV9ORVRSQ19QQVNTV09SRCIgPj4gJG5ldHJjOwp9OwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9ORVRSQ19QQVNTV09SRCIsJG51bGwpOwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9TQ1JJUFQiLCRudWxsKTsKY2QgIi93b29kcGVja2VyL3NvbWUiOwoKV3JpdGUtT3V0cHV0ICgnKyAiZWNobyBoZWxsbyB3b3JsZCInKTsKJiBlY2hvIGhlbGxvIHdvcmxkOyBpZiAoJExBU1RFWElUQ09ERSAtbmUgMCkge2V4aXQgJExBU1RFWElUQ09ERX0K" posixScriptBase64 = "CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3dvb2RwZWNrZXIvc29tZSIKY2QgIi93b29kcGVja2VyL3NvbWUiCgplY2hvICsgJ2VjaG8gaGVsbG8gd29ybGQnCmVjaG8gaGVsbG8gd29ybGQK" ) func TestGenerateContainerConf(t *testing.T) { gotEnv, gotEntry := GenerateContainerConf([]string{"echo hello world"}, "windows", "/woodpecker/some") assert.Equal(t, windowsScriptBase64, gotEnv["CI_SCRIPT"]) assert.Equal(t, "powershell.exe", gotEnv["SHELL"]) assert.Equal(t, []string{"powershell", "-noprofile", "-noninteractive", "-command", "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Env:CI_SCRIPT)) | iex"}, gotEntry) gotEnv, gotEntry = GenerateContainerConf([]string{"echo hello world"}, "linux", "/woodpecker/some") assert.Equal(t, posixScriptBase64, gotEnv["CI_SCRIPT"]) assert.Equal(t, "/bin/sh", gotEnv["SHELL"]) assert.Equal(t, []string{"/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"}, gotEntry) } ================================================ FILE: pipeline/backend/common/script_win.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "bytes" "fmt" "strings" "text/template" ) func generateScriptWindows(commands []string, workDir string) string { var buf bytes.Buffer if err := setupScriptWinTmpl.Execute(&buf, map[string]string{ "WorkDir": workDir, }); err != nil { // should never happen but well we have an error to trance return fmt.Sprintf("echo 'failed to generate posix script from commands: %s'; exit 1", err.Error()) } for _, command := range commands { escaped := fmt.Sprintf("%q", command) escaped = strings.ReplaceAll(escaped, "$", `\$`) fmt.Fprintf(&buf, traceScriptWin, escaped, command, ) } return buf.String() } const setupScriptWinProto = ` $ErrorActionPreference = 'Stop'; if (-not (Test-Path "{{.WorkDir}}")) { New-Item -Path "{{.WorkDir}}" -ItemType Directory -Force }; if (-not [Environment]::GetEnvironmentVariable('HOME')) { [Environment]::SetEnvironmentVariable('HOME', 'c:\root') }; if (-not (Test-Path "$env:HOME")) { New-Item -Path "$env:HOME" -ItemType Directory -Force }; if ($Env:CI_NETRC_MACHINE) { $netrc=[string]::Format("{0}\_netrc",$Env:HOME); "machine $Env:CI_NETRC_MACHINE" >> $netrc; "login $Env:CI_NETRC_USERNAME" >> $netrc; "password $Env:CI_NETRC_PASSWORD" >> $netrc; }; [Environment]::SetEnvironmentVariable("CI_NETRC_PASSWORD",$null); [Environment]::SetEnvironmentVariable("CI_SCRIPT",$null); cd "{{.WorkDir}}"; ` var setupScriptWinTmpl, _ = template.New("").Parse(setupScriptWinProto) // traceScript is a helper script that is added to the step script // to trace a command. const traceScriptWin = ` Write-Output ('+ %s'); & %s; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE} ` ================================================ FILE: pipeline/backend/common/script_win_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "testing" "text/template" "github.com/stretchr/testify/assert" ) func TestGenerateScriptWin(t *testing.T) { testdata := []struct { from []string want string }{ { from: []string{"echo %PATH%", "go build", "go test"}, want: ` $ErrorActionPreference = 'Stop'; if (-not (Test-Path "/woodpecker/some")) { New-Item -Path "/woodpecker/some" -ItemType Directory -Force }; if (-not [Environment]::GetEnvironmentVariable('HOME')) { [Environment]::SetEnvironmentVariable('HOME', 'c:\root') }; if (-not (Test-Path "$env:HOME")) { New-Item -Path "$env:HOME" -ItemType Directory -Force }; if ($Env:CI_NETRC_MACHINE) { $netrc=[string]::Format("{0}\_netrc",$Env:HOME); "machine $Env:CI_NETRC_MACHINE" >> $netrc; "login $Env:CI_NETRC_USERNAME" >> $netrc; "password $Env:CI_NETRC_PASSWORD" >> $netrc; }; [Environment]::SetEnvironmentVariable("CI_NETRC_PASSWORD",$null); [Environment]::SetEnvironmentVariable("CI_SCRIPT",$null); cd "/woodpecker/some"; Write-Output ('+ "echo %PATH%"'); & echo %PATH%; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE} Write-Output ('+ "go build"'); & go build; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE} Write-Output ('+ "go test"'); & go test; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE} `, }, } for _, test := range testdata { script := generateScriptWindows(test.from, "/woodpecker/some") assert.EqualValues(t, test.want, script, "Want encoded script for %s", test.from) } } func TestSetupScriptWinProtoParse(t *testing.T) { // just ensure that we have a working `setupScriptWinTmpl` on runntime _, err := template.New("").Parse(setupScriptWinProto) assert.NoError(t, err) } ================================================ FILE: pipeline/backend/docker/backend_options.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "github.com/go-viper/mapstructure/v2" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // BackendOptions defines all the advanced options for the docker backend. type BackendOptions struct { User string `mapstructure:"user"` } func parseBackendOptions(step *backend_types.Step) (BackendOptions, error) { var result BackendOptions if step == nil || step.BackendOptions == nil { return result, nil } err := mapstructure.WeakDecode(step.BackendOptions[EngineName], &result) return result, err } ================================================ FILE: pipeline/backend/docker/backend_options_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "testing" "github.com/stretchr/testify/assert" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func Test_parseBackendOptions(t *testing.T) { tests := []struct { name string step *backend_types.Step want BackendOptions wantErr bool }{ { name: "nil options", step: &backend_types.Step{BackendOptions: nil}, want: BackendOptions{}, }, { name: "empty options", step: &backend_types.Step{BackendOptions: map[string]any{}}, want: BackendOptions{}, }, { name: "with user option", step: &backend_types.Step{BackendOptions: map[string]any{ "docker": map[string]any{ "user": "1000:1000", }, }}, want: BackendOptions{User: "1000:1000"}, }, { name: "invalid backend options", step: &backend_types.Step{BackendOptions: map[string]any{"docker": "invalid"}}, want: BackendOptions{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseBackendOptions(tt.step) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pipeline/backend/docker/config.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "fmt" "strings" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" ) type config struct { enableIPv6 bool network string volumes []string resourceLimit resourceLimit stopTimeout int64 } type resourceLimit struct { MemSwapLimit int64 MemLimit int64 ShmSize int64 CPUQuota int64 CPUShares int64 CPUSet string } func configFromCli(c *cli.Command) (config, error) { conf := config{ enableIPv6: c.Bool("backend-docker-ipv6"), network: c.String("backend-docker-network"), resourceLimit: resourceLimit{ MemSwapLimit: c.Int64("backend-docker-limit-mem-swap"), MemLimit: c.Int64("backend-docker-limit-mem"), ShmSize: c.Int64("backend-docker-limit-shm-size"), CPUQuota: c.Int64("backend-docker-limit-cpu-quota"), CPUShares: c.Int64("backend-docker-limit-cpu-shares"), CPUSet: c.String("backend-docker-limit-cpu-set"), }, stopTimeout: c.Int64("backend-docker-stop-timeout"), } volumes := strings.Split(c.String("backend-docker-volumes"), ",") conf.volumes = make([]string, 0, len(volumes)) // Validate provided volume definitions for _, v := range volumes { if v == "" { continue } parts, err := splitVolumeParts(v) if err != nil { log.Error().Err(err).Msgf("can not parse volume config") return conf, fmt.Errorf("invalid volume '%s' provided in WOODPECKER_BACKEND_DOCKER_VOLUMES: %w", v, err) } conf.volumes = append(conf.volumes, strings.Join(parts, ":")) } return conf, nil } ================================================ FILE: pipeline/backend/docker/convert.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "encoding/base64" "encoding/json" "fmt" "maps" "net/netip" "regexp" "strings" "github.com/moby/moby/api/types/container" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/common" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // Valid container volumes must have at least two components, source and destination. const minVolumeComponents = 2 // returns a container configuration. func (e *docker) toConfig(step *types.Step, options BackendOptions) *container.Config { e.windowsPathPatch(step) config := &container.Config{ Image: step.Image, Labels: map[string]string{ "wp_uuid": step.UUID, "wp_step": step.Name, }, WorkingDir: step.WorkingDir, AttachStdout: true, AttachStderr: true, Volumes: toVol(step.Volumes), User: options.User, } configEnv := make(map[string]string) maps.Copy(configEnv, step.Environment) if len(step.Commands) > 0 { env, entry := common.GenerateContainerConf(step.Commands, e.info.OSType, step.WorkingDir) maps.Copy(configEnv, env) config.Entrypoint = entry // step.WorkingDir will be respected by the generated script config.WorkingDir = step.WorkspaceBase } if len(step.Entrypoint) > 0 { config.Entrypoint = step.Entrypoint } if len(configEnv) != 0 { config.Env = toEnv(configEnv) } return config } func toContainerName(step *types.Step) string { return "wp_" + step.UUID } // returns a container host configuration. func toHostConfig(step *types.Step, conf *config) (*container.HostConfig, error) { config := &container.HostConfig{ Resources: container.Resources{ CPUQuota: conf.resourceLimit.CPUQuota, CPUShares: conf.resourceLimit.CPUShares, CpusetCpus: conf.resourceLimit.CPUSet, Memory: conf.resourceLimit.MemLimit, MemorySwap: conf.resourceLimit.MemSwapLimit, }, ShmSize: conf.resourceLimit.ShmSize, LogConfig: container.LogConfig{ Type: "json-file", }, Privileged: step.Privileged, } if len(step.NetworkMode) != 0 { config.NetworkMode = container.NetworkMode(step.NetworkMode) } if len(step.DNS) != 0 { addrs := make([]netip.Addr, len(step.DNS)) for i, dns := range step.DNS { a, err := netip.ParseAddr(dns) if err != nil { return nil, fmt.Errorf("could not parse DNS address [%s]: %w", dns, err) } addrs[i] = a } config.DNS = addrs } if len(step.DNSSearch) != 0 { config.DNSSearch = step.DNSSearch } extraHosts := []string{} for _, hostAlias := range step.ExtraHosts { extraHosts = append(extraHosts, hostAlias.Name+":"+hostAlias.IP) } if len(step.ExtraHosts) != 0 { config.ExtraHosts = extraHosts } if len(step.Devices) != 0 { config.Devices = toDev(step.Devices) } if len(step.Volumes) != 0 { config.Binds = step.Volumes } config.Tmpfs = map[string]string{} for _, path := range step.Tmpfs { if !strings.Contains(path, ":") { config.Tmpfs[path] = "" continue } parts, err := splitVolumeParts(path) if err != nil { continue } config.Tmpfs[parts[0]] = parts[1] } return config, nil } // helper function that converts a slice of volume paths to a set of // unique volume names. func toVol(paths []string) map[string]struct{} { if len(paths) == 0 { return nil } set := make(map[string]struct{}) for _, path := range paths { parts, err := splitVolumeParts(path) if err != nil { continue } if len(parts) < minVolumeComponents { continue } set[parts[1]] = struct{}{} } return set } // helper function that converts a key value map of environment variables to a // string slice in key=value format. func toEnv(env map[string]string) []string { var envs []string for k, v := range env { if k != "" { envs = append(envs, k+"="+v) } } return envs } // toDev converts a slice of volume paths to a set of device mappings for // use in a Docker container config. It handles splitting the volume paths // into host and container paths, and setting default permissions. func toDev(paths []string) []container.DeviceMapping { var devices []container.DeviceMapping for _, path := range paths { parts, err := splitVolumeParts(path) if err != nil { continue } if len(parts) < minVolumeComponents { continue } if strings.HasSuffix(parts[1], ":ro") || strings.HasSuffix(parts[1], ":rw") { parts[1] = parts[1][:len(parts[1])-1] } devices = append(devices, container.DeviceMapping{ PathOnHost: parts[0], PathInContainer: parts[1], CgroupPermissions: "rwm", }) } return devices } // helper function that serializes the auth configuration as JSON // base64 payload. func encodeAuthToBase64(authConfig types.Auth) (string, error) { buf, err := json.Marshal(authConfig) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(buf), nil } // splitVolumeParts splits a volume string into its constituent parts. // // The parts are: // // 1. The path on the host machine // 2. The path inside the container // 3. The read/write mode // // It handles Windows and Linux style volume paths. func splitVolumeParts(volumeParts string) ([]string, error) { // cspell:disable-next-line pattern := `^((?:[\w]\:)?[^\:]*)\:((?:[\w]\:)?[^\:]*)(?:\:([rwom]*))?` r, err := regexp.Compile(pattern) if err != nil { return []string{}, err } if r.MatchString(volumeParts) { results := r.FindStringSubmatch(volumeParts)[1:] var cleanResults []string for _, item := range results { if item != "" { cleanResults = append(cleanResults, item) } } return cleanResults, nil } return strings.Split(volumeParts, ":"), nil } func toRef[T any](v T) *T { return &v } ================================================ FILE: pipeline/backend/docker/convert_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "encoding/base64" "reflect" "sort" "strings" "testing" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/system" "github.com/stretchr/testify/assert" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestSplitVolumeParts(t *testing.T) { testdata := []struct { from string to []string success bool }{ { from: `Z::Z::rw`, to: []string{`Z:`, `Z:`, `rw`}, success: true, }, { from: `Z:\:Z:\:rw`, to: []string{`Z:\`, `Z:\`, `rw`}, success: true, }, { from: `Z:\git\refs:Z:\git\refs:rw`, to: []string{`Z:\git\refs`, `Z:\git\refs`, `rw`}, success: true, }, { from: `Z:\git\refs:Z:\git\refs`, to: []string{`Z:\git\refs`, `Z:\git\refs`}, success: true, }, { from: `Z:/:Z:/:rw`, to: []string{`Z:/`, `Z:/`, `rw`}, success: true, }, { from: `Z:/git/refs:Z:/git/refs:rw`, to: []string{`Z:/git/refs`, `Z:/git/refs`, `rw`}, success: true, }, { from: `Z:/git/refs:Z:/git/refs`, to: []string{`Z:/git/refs`, `Z:/git/refs`}, success: true, }, { from: `/test:/test`, to: []string{`/test`, `/test`}, success: true, }, { from: `test:/test`, to: []string{`test`, `/test`}, success: true, }, { from: `test:test`, to: []string{`test`, `test`}, success: true, }, } for _, test := range testdata { results, err := splitVolumeParts(test.from) if test.success != (err == nil) { assert.Equal(t, test.success, reflect.DeepEqual(results, test.to)) } } } // dummy vars to test against. var ( testCmdStep = &backend_types.Step{ Name: "hello", UUID: "f51821af-4cb8-435e-a3c2-3a684185d828", Type: backend_types.StepTypeCommands, Commands: []string{"echo \"hello world\"", "ls"}, Image: "alpine", Environment: map[string]string{"SHELL": "/bin/zsh"}, } testPluginStep = &backend_types.Step{ Name: "lint", UUID: "d841ee40-e66e-4275-bb3f-55bf89744b21", Type: backend_types.StepTypePlugin, Image: "mstruebing/editorconfig-checker", Environment: make(map[string]string), } testEngine = &docker{ info: system.Info{ Architecture: "x86_64", OSType: "linux", DefaultRuntime: "runc", DockerRootDir: "/var/lib/docker", OperatingSystem: "Archlinux", Name: "SOME_HOSTNAME", }, } ) func TestToContainerName(t *testing.T) { assert.EqualValues(t, "wp_f51821af-4cb8-435e-a3c2-3a684185d828", toContainerName(testCmdStep)) assert.EqualValues(t, "wp_d841ee40-e66e-4275-bb3f-55bf89744b21", toContainerName(testPluginStep)) } func TestStepToConfig(t *testing.T) { // StepTypeCommands conf := testEngine.toConfig(testCmdStep, BackendOptions{}) if assert.NotNil(t, conf) { assert.EqualValues(t, []string{"/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"}, conf.Entrypoint) assert.Nil(t, conf.Cmd) assert.EqualValues(t, testCmdStep.UUID, conf.Labels["wp_uuid"]) } // StepTypePlugin conf = testEngine.toConfig(testPluginStep, BackendOptions{}) if assert.NotNil(t, conf) { assert.Nil(t, conf.Cmd) assert.EqualValues(t, testPluginStep.UUID, conf.Labels["wp_uuid"]) } } func TestToEnv(t *testing.T) { assert.Nil(t, toEnv(nil)) assert.EqualValues(t, []string{"A=B"}, toEnv(map[string]string{"A": "B"})) assert.ElementsMatch(t, []string{"A=B=C", "T=T"}, toEnv(map[string]string{"A": "B=C", "": "Z", "T": "T"})) } func TestToVol(t *testing.T) { assert.Nil(t, toVol(nil)) assert.EqualValues(t, map[string]struct{}{"/test": {}}, toVol([]string{"test:/test"})) } func TestEncodeAuthToBase64(t *testing.T) { res, err := encodeAuthToBase64(backend_types.Auth{}) assert.NoError(t, err) assert.EqualValues(t, "e30=", res) res, err = encodeAuthToBase64(backend_types.Auth{Username: "user", Password: "pwd"}) assert.NoError(t, err) assert.EqualValues(t, "eyJ1c2VybmFtZSI6InVzZXIiLCJwYXNzd29yZCI6InB3ZCJ9", res) } func TestToConfigSmall(t *testing.T) { engine := docker{info: system.Info{OSType: "linux", Architecture: "riscv64"}} conf := engine.toConfig(&backend_types.Step{ Name: "test", UUID: "09238932", Commands: []string{"go test"}, }, BackendOptions{}) assert.NotNil(t, conf) sort.Strings(conf.Env) assert.EqualValues(t, &container.Config{ AttachStdout: true, AttachStderr: true, Entrypoint: []string{"/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"}, Labels: map[string]string{ "wp_step": "test", "wp_uuid": "09238932", }, Env: []string{ "CI_SCRIPT=CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiIgpjZCAiIgoKZWNobyArICdnbyB0ZXN0JwpnbyB0ZXN0Cg==", "SHELL=/bin/sh", }, }, conf) } func TestToConfigFull(t *testing.T) { engine := docker{ info: system.Info{OSType: "linux", Architecture: "riscv64"}, config: config{ enableIPv6: true, resourceLimit: resourceLimit{ MemSwapLimit: 12, MemLimit: 13, ShmSize: 14, CPUQuota: 15, CPUShares: 16, }, }, } conf := engine.toConfig(&backend_types.Step{ Name: "test", UUID: "09238932", Type: backend_types.StepTypeCommands, Image: "golang:1.2.3", Pull: true, Detached: true, Privileged: true, WorkingDir: "/src/abc", WorkspaceBase: "/src", Environment: map[string]string{"TAGS": "sqlite"}, Commands: []string{"go test", "go vet ./..."}, ExtraHosts: []backend_types.HostAlias{{Name: "t", IP: "1.2.3.4"}}, Volumes: []string{"/cache:/cache"}, Tmpfs: []string{"/tmp"}, Devices: []string{"/dev/sdc"}, Networks: []backend_types.Conn{{Name: "extra-net", Aliases: []string{"extra.net"}}}, DNS: []string{"9.9.9.9", "8.8.8.8"}, DNSSearch: nil, OnFailure: true, OnSuccess: true, Failure: "fail", AuthConfig: backend_types.Auth{Username: "user", Password: "123456"}, NetworkMode: "bridge", Ports: []backend_types.Port{{Number: 21}, {Number: 22}}, }, BackendOptions{}) assert.NotNil(t, conf) sort.Strings(conf.Env) assert.EqualValues(t, &container.Config{ Image: "golang:1.2.3", WorkingDir: "/src", AttachStdout: true, AttachStderr: true, Entrypoint: []string{"/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e"}, Labels: map[string]string{ "wp_step": "test", "wp_uuid": "09238932", }, Env: []string{ "CI_SCRIPT=CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3NyYy9hYmMiCmNkICIvc3JjL2FiYyIKCmVjaG8gKyAnZ28gdGVzdCcKZ28gdGVzdAoKZWNobyArICdnbyB2ZXQgLi8uLi4nCmdvIHZldCAuLy4uLgo=", "SHELL=/bin/sh", "TAGS=sqlite", }, Volumes: map[string]struct{}{ "/cache": {}, }, }, conf) } func TestToWindowsConfig(t *testing.T) { engine := docker{ info: system.Info{OSType: "windows", Architecture: "x86_64"}, config: config{ enableIPv6: true, }, } conf := engine.toConfig(&backend_types.Step{ Name: "test", UUID: "23434553", Type: backend_types.StepTypeCommands, Image: "golang:1.2.3", WorkingDir: "/src/abc", WorkspaceBase: "/src", Environment: map[string]string{ "TAGS": "sqlite", "CI_WORKSPACE": "/src", }, Commands: []string{"go test", "go vet ./..."}, ExtraHosts: []backend_types.HostAlias{{Name: "t", IP: "1.2.3.4"}}, Volumes: []string{"wp_default_abc:/src", "/cache:/cache/some/more", "test:/test"}, Networks: []backend_types.Conn{{Name: "extra-net", Aliases: []string{"extra.net"}}}, DNS: []string{"9.9.9.9", "8.8.8.8"}, Failure: "fail", AuthConfig: backend_types.Auth{Username: "user", Password: "123456"}, NetworkMode: "nat", Ports: []backend_types.Port{{Number: 21}, {Number: 22}}, }, BackendOptions{}) assert.NotNil(t, conf) sort.Strings(conf.Env) assert.EqualValues(t, &container.Config{ Image: "golang:1.2.3", WorkingDir: "C:/src", AttachStdout: true, AttachStderr: true, Entrypoint: []string{"powershell", "-noprofile", "-noninteractive", "-command", "[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Env:CI_SCRIPT)) | iex"}, Labels: map[string]string{ "wp_step": "test", "wp_uuid": "23434553", }, Env: []string{ "CI_SCRIPT=CiRFcnJvckFjdGlvblByZWZlcmVuY2UgPSAnU3RvcCc7CmlmICgtbm90IChUZXN0LVBhdGggIkM6L3NyYy9hYmMiKSkgeyBOZXctSXRlbSAtUGF0aCAiQzovc3JjL2FiYyIgLUl0ZW1UeXBlIERpcmVjdG9yeSAtRm9yY2UgfTsKaWYgKC1ub3QgW0Vudmlyb25tZW50XTo6R2V0RW52aXJvbm1lbnRWYXJpYWJsZSgnSE9NRScpKSB7IFtFbnZpcm9ubWVudF06OlNldEVudmlyb25tZW50VmFyaWFibGUoJ0hPTUUnLCAnYzpccm9vdCcpIH07CmlmICgtbm90IChUZXN0LVBhdGggIiRlbnY6SE9NRSIpKSB7IE5ldy1JdGVtIC1QYXRoICIkZW52OkhPTUUiIC1JdGVtVHlwZSBEaXJlY3RvcnkgLUZvcmNlIH07CmlmICgkRW52OkNJX05FVFJDX01BQ0hJTkUpIHsKJG5ldHJjPVtzdHJpbmddOjpGb3JtYXQoInswfVxfbmV0cmMiLCRFbnY6SE9NRSk7CiJtYWNoaW5lICRFbnY6Q0lfTkVUUkNfTUFDSElORSIgPj4gJG5ldHJjOwoibG9naW4gJEVudjpDSV9ORVRSQ19VU0VSTkFNRSIgPj4gJG5ldHJjOwoicGFzc3dvcmQgJEVudjpDSV9ORVRSQ19QQVNTV09SRCIgPj4gJG5ldHJjOwp9OwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9ORVRSQ19QQVNTV09SRCIsJG51bGwpOwpbRW52aXJvbm1lbnRdOjpTZXRFbnZpcm9ubWVudFZhcmlhYmxlKCJDSV9TQ1JJUFQiLCRudWxsKTsKY2QgIkM6L3NyYy9hYmMiOwoKV3JpdGUtT3V0cHV0ICgnKyAiZ28gdGVzdCInKTsKJiBnbyB0ZXN0OyBpZiAoJExBU1RFWElUQ09ERSAtbmUgMCkge2V4aXQgJExBU1RFWElUQ09ERX0KCldyaXRlLU91dHB1dCAoJysgImdvIHZldCAuLy4uLiInKTsKJiBnbyB2ZXQgLi8uLi47IGlmICgkTEFTVEVYSVRDT0RFIC1uZSAwKSB7ZXhpdCAkTEFTVEVYSVRDT0RFfQo=", "CI_WORKSPACE=C:/src", "SHELL=powershell.exe", "TAGS=sqlite", }, Volumes: map[string]struct{}{ "C:/cache/some/more": {}, "C:/src": {}, "C:/test": {}, }, }, conf) ciScript, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(conf.Env[0], "CI_SCRIPT=")) if assert.NoError(t, err) { assert.EqualValues(t, ` $ErrorActionPreference = 'Stop'; if (-not (Test-Path "C:/src/abc")) { New-Item -Path "C:/src/abc" -ItemType Directory -Force }; if (-not [Environment]::GetEnvironmentVariable('HOME')) { [Environment]::SetEnvironmentVariable('HOME', 'c:\root') }; if (-not (Test-Path "$env:HOME")) { New-Item -Path "$env:HOME" -ItemType Directory -Force }; if ($Env:CI_NETRC_MACHINE) { $netrc=[string]::Format("{0}\_netrc",$Env:HOME); "machine $Env:CI_NETRC_MACHINE" >> $netrc; "login $Env:CI_NETRC_USERNAME" >> $netrc; "password $Env:CI_NETRC_PASSWORD" >> $netrc; }; [Environment]::SetEnvironmentVariable("CI_NETRC_PASSWORD",$null); [Environment]::SetEnvironmentVariable("CI_SCRIPT",$null); cd "C:/src/abc"; Write-Output ('+ "go test"'); & go test; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE} Write-Output ('+ "go vet ./..."'); & go vet ./...; if ($LASTEXITCODE -ne 0) {exit $LASTEXITCODE} `, string(ciScript)) } } ================================================ FILE: pipeline/backend/docker/convert_win.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "path/filepath" "regexp" "strings" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) const ( osTypeWindows = "windows" defaultWindowsDriverLetter = "C:" ) var MustNotAddWindowsLetterPattern = regexp.MustCompile(`^(?:` + // Drive letter followed by colon and optional backslash (C: or C:\) `[a-zA-Z]:(?:\\|$)|` + // Device path starting with \\ or // followed by .\ or ./ (\\.\ or //./ or \\./ or //.\ ) `(?:\\\\|//)\.(?:\\|/).*|` + // UNC path starting with \\ or // followed by non-dot (\server or //server) `(?:\\\\|//)[^.]|` + // Relative path starting with .\ or ./ (.\path or ./path) `\.(?:\\|/)` + `)`) func (e *docker) windowsPathPatch(step *types.Step) { // only patch if target is windows if strings.ToLower(e.info.OSType) != osTypeWindows { return } // patch volumes to have an letter if not already set for i, vol := range step.Volumes { volParts, err := splitVolumeParts(vol) if err != nil || len(volParts) < 2 { // ignore non valid volumes for now continue } // fix source destination if strings.HasPrefix(volParts[0], "/") { volParts[0] = filepath.Join(defaultWindowsDriverLetter, volParts[0]) } // fix mount destination if !MustNotAddWindowsLetterPattern.MatchString(volParts[1]) { volParts[1] = filepath.Join(defaultWindowsDriverLetter, volParts[1]) } step.Volumes[i] = strings.Join(volParts, ":") } // patch workspace if !MustNotAddWindowsLetterPattern.MatchString(step.WorkspaceBase) { step.WorkspaceBase = filepath.Join(defaultWindowsDriverLetter, step.WorkspaceBase) } if !MustNotAddWindowsLetterPattern.MatchString(step.WorkingDir) { step.WorkingDir = filepath.Join(defaultWindowsDriverLetter, step.WorkingDir) } if ciWorkspace, ok := step.Environment["CI_WORKSPACE"]; ok { if !MustNotAddWindowsLetterPattern.MatchString(ciWorkspace) { step.Environment["CI_WORKSPACE"] = filepath.Join(defaultWindowsDriverLetter, ciWorkspace) } } } ================================================ FILE: pipeline/backend/docker/convert_win_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import "testing" func TestMustNotAddWindowsLetterPattern(t *testing.T) { tests := map[string]bool{ `C:\Users`: true, `D:\Data`: true, `\\.\PhysicalDrive0`: true, `//./COM1`: true, `E:`: true, `\\server\share`: true, // UNC path `.\relative\path`: true, // Relative path `./path`: true, // Relative with forward slash `//server/share`: true, // UNC with forward slashes `not/a/windows/path`: false, ``: false, `/usr/local`: false, `COM1`: false, `\\.`: false, // Incomplete device path `//`: false, } for testCase, expected := range tests { result := MustNotAddWindowsLetterPattern.MatchString(testCase) if result != expected { t.Errorf("Test case %q: expected %v but got %v", testCase, expected, result) } } } ================================================ FILE: pipeline/backend/docker/docker.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "context" "errors" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/cenkalti/backoff/v5" "github.com/containerd/errdefs" "github.com/docker/go-connections/tlsconfig" "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/network" "github.com/moby/moby/api/types/system" "github.com/moby/moby/client" "github.com/moby/moby/client/pkg/jsonmessage" "github.com/moby/term" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "golang.org/x/sync/errgroup" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( containerKillTimeout = 5 // seconds volumeRetryWait time.Duration = 1 * time.Second maxRetry uint = 3 ) type docker struct { client client.APIClient info system.Info config config } const ( EngineName = "docker" networkDriverNAT = "nat" networkDriverBridge = "bridge" volumeDriver = "local" ) // New returns a new Docker Backend. func New() backend_types.Backend { return &docker{ client: nil, } } func (e *docker) Name() string { return EngineName } func (e *docker) IsAvailable(ctx context.Context) bool { if c, ok := ctx.Value(backend_types.CliCommand).(*cli.Command); ok { if c.IsSet("backend-docker-host") { return true } } _, err := os.Stat("/var/run/docker.sock") return err == nil } func httpClientOfOpts(dockerCertPath string, verifyTLS bool) *http.Client { if dockerCertPath == "" { return nil } options := tlsconfig.Options{ CAFile: filepath.Join(dockerCertPath, "ca.pem"), CertFile: filepath.Join(dockerCertPath, "cert.pem"), KeyFile: filepath.Join(dockerCertPath, "key.pem"), InsecureSkipVerify: !verifyTLS, } tlsConf, err := tlsconfig.Client(options) if err != nil { log.Error().Err(err).Msg("could not create http client out of docker backend options") return nil } return &http.Client{ Transport: httputil.NewUserAgentRoundTripper( &http.Transport{TLSClientConfig: tlsConf}, "backend-docker"), CheckRedirect: client.CheckRedirect, } } func (e *docker) Flags() []cli.Flag { return Flags } // Load new client for Docker Backend using environment variables. func (e *docker) Load(ctx context.Context) (*backend_types.BackendInfo, error) { c, ok := ctx.Value(backend_types.CliCommand).(*cli.Command) if !ok { return nil, backend_types.ErrNoCliContextFound } var dockerClientOpts []client.Opt if httpClient := httpClientOfOpts(c.String("backend-docker-cert"), c.Bool("backend-docker-tls-verify")); httpClient != nil { dockerClientOpts = append(dockerClientOpts, client.WithHTTPClient(httpClient)) } if dockerHost := c.String("backend-docker-host"); dockerHost != "" { dockerClientOpts = append(dockerClientOpts, client.WithHost(dockerHost)) } if dockerAPIVersion := c.String("backend-docker-api-version"); dockerAPIVersion != "" { dockerClientOpts = append(dockerClientOpts, client.WithAPIVersion(dockerAPIVersion)) } cl, err := client.New(dockerClientOpts...) if err != nil { return nil, err } e.client = cl info, err := cl.Info(ctx, client.InfoOptions{}) if err != nil { return nil, err } e.info = info.Info e.config, err = configFromCli(c) if err != nil { return nil, err } return &backend_types.BackendInfo{ Platform: e.info.OSType + "/" + normalizeArchType(e.info.Architecture), }, nil } func (e *docker) SetupWorkflow(ctx context.Context, conf *backend_types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msg("create workflow environment") _, err := e.client.VolumeCreate(ctx, client.VolumeCreateOptions{ Name: conf.Volume, Driver: volumeDriver, }) if err != nil { return err } networkDriver := networkDriverBridge if e.info.OSType == "windows" { networkDriver = networkDriverNAT } _, err = e.client.NetworkCreate(ctx, conf.Network, client.NetworkCreateOptions{ Driver: networkDriver, EnableIPv6: &e.config.enableIPv6, }) return err } func (e *docker) StartStep(ctx context.Context, step *backend_types.Step, taskUUID string) error { options, err := parseBackendOptions(step) if err != nil { log.Error().Err(err).Msg("could not parse backend options") } log.Trace().Str("taskUUID", taskUUID).Msgf("start step %s", step.Name) config := e.toConfig(step, options) hostConfig, err := toHostConfig(step, &e.config) if err != nil { return err } containerName := toContainerName(step) // create pull options with encoded authorization credentials. pullOpts := client.ImagePullOptions{} if step.AuthConfig.Username != "" && step.AuthConfig.Password != "" { pullOpts.RegistryAuth, _ = encodeAuthToBase64(step.AuthConfig) } // automatically pull the latest version of the image if requested // by the process configuration. if step.Pull { responseBody, pErr := e.client.ImagePull(ctx, config.Image, pullOpts) if pErr == nil { // TODO(1936): show image pull progress in web-ui fd, isTerminal := term.GetFdInfo(os.Stdout) if err := jsonmessage.DisplayJSONMessagesStream(responseBody, os.Stdout, fd, isTerminal, nil); err != nil { log.Error().Err(err).Msg("DisplayJSONMessagesStream") } responseBody.Close() } // Fix "Show warning when fail to auth to docker registry" // (https://web.archive.org/web/20201023145804/https://github.com/drone/drone/issues/1917) if pErr != nil && step.AuthConfig.Password != "" { return pErr } } // add default volumes to the host configuration hostConfig.Binds = utils.DeduplicateStrings(append(hostConfig.Binds, e.config.volumes...)) _, err = e.client.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: config, HostConfig: hostConfig, Name: containerName, }) if errdefs.IsNotFound(err) { // automatically pull and try to re-create the image if the // failure is caused because the image does not exist. responseBody, pErr := e.client.ImagePull(ctx, config.Image, pullOpts) if pErr != nil { return pErr } // TODO(1936): show image pull progress in web-ui fd, isTerminal := term.GetFdInfo(os.Stdout) if err := jsonmessage.DisplayJSONMessagesStream(responseBody, os.Stdout, fd, isTerminal, nil); err != nil { log.Error().Err(err).Msg("DisplayJSONMessagesStream") } responseBody.Close() _, err = e.client.ContainerCreate(ctx, client.ContainerCreateOptions{ Config: config, HostConfig: hostConfig, Name: containerName, }) } if err != nil { return err } if len(step.NetworkMode) == 0 { for _, net := range step.Networks { _, err = e.client.NetworkConnect(ctx, net.Name, client.NetworkConnectOptions{ EndpointConfig: &network.EndpointSettings{ Aliases: net.Aliases, }, Container: containerName, }) if err != nil { return err } } // join the container to an existing network if e.config.network != "" { _, err = e.client.NetworkConnect(ctx, e.config.network, client.NetworkConnectOptions{ Container: containerName, }) if err != nil { return err } } } _, err = e.client.ContainerStart(ctx, containerName, client.ContainerStartOptions{}) return err } func (e *docker) WaitStep(ctx context.Context, step *backend_types.Step, taskUUID string) (*backend_types.State, error) { log := log.Logger.With().Str("taskUUID", taskUUID).Str("stepUUID", step.UUID).Logger() log.Trace().Msgf("wait for step %s", step.Name) containerName := toContainerName(step) wait := e.client.ContainerWait(ctx, containerName, client.ContainerWaitOptions{}) select { case resp := <-wait.Result: log.Trace().Msgf("ContainerWait returned with resp: %v", resp) if resp.Error != nil { return nil, fmt.Errorf("ContainerWait error: %s", resp.Error.Message) } case err := <-wait.Error: log.Trace().Msgf("ContainerWait returned with err: %v", err) return nil, err case <-ctx.Done(): return nil, ctx.Err() } info, err := e.client.ContainerInspect(ctx, containerName, client.ContainerInspectOptions{}) if err != nil { return nil, err } exitCode := info.Container.State.ExitCode // Windows Docker may return 4294967295 (uint32 max, i.e. int32(-1)) for abnormal exits. if int64(exitCode) == int64(4294967295) { //nolint:mnd // because it is int(^uint32(0)) exitCode = -1 } return &backend_types.State{ Exited: true, ExitCode: exitCode, OOMKilled: info.Container.State.OOMKilled, }, nil } func (e *docker) TailStep(ctx context.Context, step *backend_types.Step, taskUUID string) (io.ReadCloser, error) { log.Trace().Str("taskUUID", taskUUID).Msgf("tail logs of step %s", step.Name) logs, err := e.client.ContainerLogs(ctx, toContainerName(step), client.ContainerLogsOptions{ Follow: true, ShowStdout: true, ShowStderr: true, Details: false, Timestamps: false, }) if err != nil { return nil, err } rc, wc := io.Pipe() // de multiplex 'logs' who contains two streams, previously multiplexed together using StdWriter go func() { _, _ = stdcopy.StdCopy(wc, wc, logs) _ = logs.Close() _ = wc.Close() }() return rc, nil } func (e *docker) DestroyStep(ctx context.Context, step *backend_types.Step, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("stop step %s", step.Name) containerName := toContainerName(step) var stopErr error // we first signal to the container to stop ... if _, err := e.client.ContainerStop(ctx, containerName, client.ContainerStopOptions{ Timeout: toRef(int(e.config.stopTimeout)), }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { // we do not return error yet as we try to kill it first stopErr = fmt.Errorf("could not stop container '%s': %w", step.Name, err) } // ... and if stop does not work just force kill it if _, err := e.client.ContainerKill(ctx, containerName, client.ContainerKillOptions{ Signal: "9", }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { return errors.Join(stopErr, fmt.Errorf("could not kill container '%s': %w", step.Name, err)) } // now we clean up files left if _, err := e.client.ContainerRemove(ctx, containerName, removeOpts); err != nil && !isErrContainerNotFoundOrNotRunning(err) { return fmt.Errorf("could not remove container '%s': %w", step.Name, err) } return nil } func (e *docker) DestroyWorkflow(ctx context.Context, conf *backend_types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("delete workflow environment") errWG := errgroup.Group{} for _, stage := range conf.Stages { for _, step := range stage.Steps { errWG.Go(func() error { return e.DestroyStep(ctx, step, taskUUID) }) } } if err := errWG.Wait(); err != nil { log.Error().Err(err).Msgf("could not destroy all containers") } var err error _, _ = backoff.Retry(ctx, func() (any, error) { _, err = e.client.VolumeRemove(ctx, conf.Volume, client.VolumeRemoveOptions{ Force: true, }) if err == nil || !isErrVolumeInUse(err) { // if it worked or if we have no "in use error" do not retry return nil, nil } return nil, err }, backoff.WithMaxTries(maxRetry), backoff.WithBackOff(&backoff.ExponentialBackOff{ InitialInterval: volumeRetryWait, Multiplier: 2, //nolint:mnd })) if err != nil { log.Error().Err(err).Msgf("could not remove volume '%s'", conf.Volume) } if _, err := e.client.NetworkRemove(ctx, conf.Network, client.NetworkRemoveOptions{}); err != nil { log.Error().Err(err).Msgf("could not remove network '%s'", conf.Network) } return nil } var removeOpts = client.ContainerRemoveOptions{ RemoveVolumes: true, RemoveLinks: false, Force: false, } // normalizeArchType converts the arch type reported by docker info into // the runtime.GOARCH format // TODO: find out if we we need to convert other arch types too func normalizeArchType(s string) string { switch s { case "x86_64": return "amd64" case "aarch64": return "arm64" default: return s } } ================================================ FILE: pipeline/backend/docker/errors.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import "strings" func isErrContainerNotFoundOrNotRunning(err error) bool { // Error response from daemon: Cannot kill container: ...: No such container: ... // Error response from daemon: Cannot kill container: ...: Container ... is not running" // Error response from podman daemon: can only kill running containers. ... is in state exited // Error response from daemon: removal of container ... is already in progress // Error: No such container: ... return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers") || (strings.Contains(err.Error(), "removal of container") && strings.Contains(err.Error(), "is already in progress"))) } func isErrVolumeInUse(err error) bool { return err != nil && strings.Contains(err.Error(), "volume is in use") } ================================================ FILE: pipeline/backend/docker/flags.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package docker import ( "github.com/urfave/cli/v3" ) var Flags = []cli.Flag{ &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_HOST", "DOCKER_HOST"), Name: "backend-docker-host", Usage: "path to docker socket or url to the docker server", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_API_VERSION", "DOCKER_API_VERSION"), Name: "backend-docker-api-version", Usage: "the version of the API to reach, leave empty for latest.", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_CERT_PATH", "DOCKER_CERT_PATH"), Name: "backend-docker-cert", Usage: "path to load the TLS certificates for connecting to docker server", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_TLS_VERIFY", "DOCKER_TLS_VERIFY"), Name: "backend-docker-tls-verify", Usage: "enable or disable TLS verification for connecting to docker server", Value: true, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_ENABLE_IPV6"), Name: "backend-docker-ipv6", Usage: "backend docker enable IPV6", Value: false, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_NETWORK"), Name: "backend-docker-network", Usage: "backend docker network", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_VOLUMES"), Name: "backend-docker-volumes", Usage: "backend docker volumes (comma separated)", }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_STOP_TIMEOUT"), Name: "backend-docker-stop-timeout", Usage: "seconds Woodpecker waits for a container to stop gracefully before forcefully killing it", Value: 20, //nolint:mnd }, // // resource limit parameters // &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_LIMIT_MEM_SWAP", "WOODPECKER_LIMIT_MEM_SWAP"), Name: "backend-docker-limit-mem-swap", Usage: "maximum memory used for swap in bytes", }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_LIMIT_MEM", "WOODPECKER_LIMIT_MEM"), Name: "backend-docker-limit-mem", Usage: "maximum memory allowed in bytes", }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_LIMIT_SHM_SIZE", "WOODPECKER_LIMIT_SHM_SIZE"), Name: "backend-docker-limit-shm-size", Usage: "docker /dev/shm allowed in bytes", }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_QUOTA", "WOODPECKER_LIMIT_CPU_QUOTA"), Name: "backend-docker-limit-cpu-quota", Usage: "impose a cpu quota", }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SHARES", "WOODPECKER_LIMIT_CPU_SHARES"), Name: "backend-docker-limit-cpu-shares", Usage: "change the cpu shares", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_DOCKER_LIMIT_CPU_SET", "WOODPECKER_LIMIT_CPU_SET"), Name: "backend-docker-limit-cpu-set", Usage: "set the cpus allowed to execute containers", }, } ================================================ FILE: pipeline/backend/dummy/dummy.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package dummy import ( "context" "fmt" "io" "strconv" "strings" "sync" "time" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) type dummy struct { kv sync.Map } const ( // Step names to control behavior of dummy backend. WorkflowSetupFailUUID = "WorkflowSetupShouldFail" EnvKeyStepSleep = "SLEEP" EnvKeyStepType = "EXPECT_TYPE" EnvKeyStepStartFail = "STEP_START_FAIL" EnvKeyStepExitCode = "STEP_EXIT_CODE" EnvKeyStepTailFail = "STEP_TAIL_FAIL" EnvKeyStepOOMKilled = "STEP_OOM_KILLED" // Internal const. stepStateStarted = "started" stepStateDone = "done" testServiceTimeout = 1 * time.Second // ExitCodeCanceled is the exit code returned when a step's context is // canceled while it is sleeping. 130 matches the SIGINT shell convention // (128 + signal 2) used by real container runtimes. ExitCodeCanceled = 130 ) // stepKey returns the kv-store key for a step's state. func stepKey(taskUUID, stepUUID string) string { return "task_" + taskUUID + "_step_" + stepUUID } // workflowKey returns the kv-store key for a workflow's state. func workflowKey(taskUUID string) string { return "task_" + taskUUID } // New returns a dummy backend. func New() backend_types.Backend { return &dummy{ kv: sync.Map{}, } } func (e *dummy) Name() string { return "dummy" } func (e *dummy) IsAvailable(_ context.Context) bool { return true } func (e *dummy) Flags() []cli.Flag { return nil } // Load new client for Docker Backend using environment variables. func (e *dummy) Load(_ context.Context) (*backend_types.BackendInfo, error) { return &backend_types.BackendInfo{ Platform: "dummy", }, nil } func (e *dummy) SetupWorkflow(_ context.Context, _ *backend_types.Config, taskUUID string) error { if taskUUID == WorkflowSetupFailUUID { return fmt.Errorf("expected fail to setup workflow") } log.Trace().Str("taskUUID", taskUUID).Msg("create workflow environment") e.kv.Store(workflowKey(taskUUID), make(chan struct{})) return nil } func (e *dummy) StartStep(_ context.Context, step *backend_types.Step, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("start step %s", step.Name) // internal state checks _, exist := e.kv.Load(workflowKey(taskUUID)) if !exist { return fmt.Errorf("expect env of workflow %s to exist but found none to destroy", taskUUID) } key := stepKey(taskUUID, step.UUID) stepState, stepExist := e.kv.Load(key) if stepExist { // Detect issues like https://github.com/woodpecker-ci/woodpecker/issues/3494 return fmt.Errorf("StartStep detected already started step '%s' (%s) in state: %s", step.Name, step.UUID, stepState) } if stepStartFail, _ := strconv.ParseBool(step.Environment[EnvKeyStepStartFail]); stepStartFail { return fmt.Errorf("expected fail to start step") } expectStepType, testStepType := step.Environment[EnvKeyStepType] if testStepType && string(step.Type) != expectStepType { return fmt.Errorf("expected step type '%s' but got '%s'", expectStepType, step.Type) } e.kv.Store(key, stepStateStarted) return nil } // canceledState returns the state for a step whose context was canceled. func canceledState() *backend_types.State { return &backend_types.State{ExitCode: ExitCodeCanceled, Exited: true} } // sleepWithContext blocks for the given duration or until ctx is canceled. // Returns true if canceled, false if the sleep completed normally. func sleepWithContext(ctx context.Context, stop <-chan struct{}, d time.Duration) (canceled bool) { if ctx.Err() != nil { return true } t := time.NewTimer(d) defer t.Stop() select { case <-t.C: return false case <-ctx.Done(): return true case <-stop: return false } } func (e *dummy) WaitStep(ctx context.Context, step *backend_types.Step, taskUUID string) (*backend_types.State, error) { log.Trace().Str("taskUUID", taskUUID).Msgf("wait for step %s", step.Name) rawWC, exist := e.kv.Load(workflowKey(taskUUID)) if !exist { err := fmt.Errorf("expect env of workflow %s to exist but found none to destroy", taskUUID) return &backend_types.State{Error: err}, err } wc, ok := rawWC.(chan struct{}) if !ok { return nil, fmt.Errorf("workflow stop chan not found") } key := stepKey(taskUUID, step.UUID) // check state stepState, stepExist := e.kv.Load(key) if !stepExist { err := fmt.Errorf("WaitStep expect step '%s' (%s) to be created but found none", step.Name, step.UUID) return &backend_types.State{Error: err}, err } if stepState != stepStateStarted { err := fmt.Errorf("WaitStep expect step '%s' (%s) to be '%s' but it is: %s", step.Name, step.UUID, stepStateStarted, stepState) return &backend_types.State{Error: err}, err } if sleep, sleepExist := step.Environment[EnvKeyStepSleep]; sleepExist { toSleep, err := time.ParseDuration(sleep) if err != nil { err = fmt.Errorf("WaitStep fail to parse sleep duration: %w", err) return &backend_types.State{Error: err}, err } if sleepWithContext(ctx, wc, toSleep) { e.kv.Store(key, stepStateDone) return canceledState(), nil } } else if step.Type == backend_types.StepTypeService { if sleepWithContext(ctx, wc, testServiceTimeout) { // context for service closed — we can move forward } else { err := fmt.Errorf("WaitStep fail due to timeout of service after 1 second") return &backend_types.State{Error: err}, err } } else { time.Sleep(time.Nanosecond) } e.kv.Store(key, stepStateDone) oomKilled, _ := strconv.ParseBool(step.Environment[EnvKeyStepOOMKilled]) exitCode := 0 if code, exist := step.Environment[EnvKeyStepExitCode]; exist { exitCode, _ = strconv.Atoi(strings.TrimSpace(code)) } return &backend_types.State{ ExitCode: exitCode, Exited: true, OOMKilled: oomKilled, }, nil } func (e *dummy) TailStep(_ context.Context, step *backend_types.Step, taskUUID string) (io.ReadCloser, error) { log.Trace().Str("taskUUID", taskUUID).Msgf("tail logs of step %s", step.Name) _, exist := e.kv.Load(workflowKey(taskUUID)) if !exist { return nil, fmt.Errorf("expect env of workflow %s to exist but found none to destroy", taskUUID) } key := stepKey(taskUUID, step.UUID) // check state stepState, stepExist := e.kv.Load(key) if !stepExist { return nil, fmt.Errorf("TailStep expect step '%s' (%s) to be created but found none", step.Name, step.UUID) } if stepState != stepStateStarted { return nil, fmt.Errorf("TailStep expect step '%s' (%s) to be '%s' but it is: %s", step.Name, step.UUID, stepStateStarted, stepState) } if tailShouldFail, _ := strconv.ParseBool(step.Environment[EnvKeyStepTailFail]); tailShouldFail { return nil, fmt.Errorf("expected fail to read stdout of step") } return io.NopCloser(strings.NewReader(dummyExecStepOutput(step))), nil } func (e *dummy) DestroyStep(_ context.Context, step *backend_types.Step, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("stop step %s", step.Name) _, exist := e.kv.Load(workflowKey(taskUUID)) if !exist { return nil } key := stepKey(taskUUID, step.UUID) // check state stepState, stepExist := e.kv.Load(key) if !stepExist { return fmt.Errorf("DestroyStep expect step '%s' (%s) to be created but found none", step.Name, step.UUID) } // Allow destroying a step in 'started' state: this happens when the // workflow context is canceled before WaitStep completes. if stepState != stepStateDone && stepState != stepStateStarted { return fmt.Errorf("DestroyStep expect step '%s' (%s) to be '%s' or '%s' but it is: %s", step.Name, step.UUID, stepStateDone, stepStateStarted, stepState) } e.kv.Delete(key) return nil } func (e *dummy) DestroyWorkflow(_ context.Context, _ *backend_types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("delete workflow environment") rawWC, exist := e.kv.Load(workflowKey(taskUUID)) if !exist { return fmt.Errorf("expect env of workflow %s to exist but found none to destroy", taskUUID) } wc, ok := rawWC.(chan struct{}) if !ok { return fmt.Errorf("workflow stop chan not found") } close(wc) e.kv.Delete(workflowKey(taskUUID)) return nil } func dummyExecStepOutput(step *backend_types.Step) string { return fmt.Sprintf(`StepName: %s StepType: %s StepUUID: %s StepCommands: ------------------ %s ------------------ `, step.Name, step.Type, step.UUID, strings.Join(step.Commands, "\n")) } ================================================ FILE: pipeline/backend/dummy/dummy_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dummy_test import ( "context" "io" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestSmalPipelineDummyRun(t *testing.T) { dummyEngine := dummy.New() ctx := t.Context() assert.True(t, dummyEngine.IsAvailable(ctx)) assert.EqualValues(t, "dummy", dummyEngine.Name()) _, err := dummyEngine.Load(ctx) require.NoError(t, err) assert.Error(t, dummyEngine.SetupWorkflow(ctx, nil, dummy.WorkflowSetupFailUUID)) t.Run("expect fail of step func with non setup workflow", func(t *testing.T) { step := &types.Step{Name: "step1", UUID: "SID_1"} nonExistWorkflowID := "WID_NONE" err := dummyEngine.StartStep(ctx, step, nonExistWorkflowID) assert.Error(t, err) _, err = dummyEngine.TailStep(ctx, step, nonExistWorkflowID) assert.Error(t, err) _, err = dummyEngine.WaitStep(ctx, step, nonExistWorkflowID) assert.Error(t, err) }) t.Run("step exec successfully", func(t *testing.T) { step := &types.Step{ Name: "step1", UUID: "SID_1", Type: types.StepTypeCommands, Environment: map[string]string{}, Commands: []string{"echo ja", "echo nein"}, } workflowUUID := "WID_1" assert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID)) assert.NoError(t, dummyEngine.StartStep(ctx, step, workflowUUID)) reader, err := dummyEngine.TailStep(ctx, step, workflowUUID) assert.NoError(t, err) log, err := io.ReadAll(reader) assert.NoError(t, err) assert.EqualValues(t, `StepName: step1 StepType: commands StepUUID: SID_1 StepCommands: ------------------ echo ja echo nein ------------------ `, string(log)) state, err := dummyEngine.WaitStep(ctx, step, workflowUUID) assert.NoError(t, err) assert.NoError(t, state.Error) assert.EqualValues(t, 0, state.ExitCode) assert.NoError(t, dummyEngine.DestroyStep(ctx, step, workflowUUID)) assert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID)) }) t.Run("step exec error", func(t *testing.T) { step := &types.Step{ Name: "dummy", UUID: "SID_2", Type: types.StepTypePlugin, Environment: map[string]string{dummy.EnvKeyStepType: "plugin", dummy.EnvKeyStepExitCode: "1"}, } workflowUUID := "WID_1" assert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID)) assert.NoError(t, dummyEngine.StartStep(ctx, step, workflowUUID)) _, err := dummyEngine.TailStep(ctx, step, workflowUUID) assert.NoError(t, err) state, err := dummyEngine.WaitStep(ctx, step, workflowUUID) assert.NoError(t, err) assert.NoError(t, state.Error) assert.EqualValues(t, 1, state.ExitCode) assert.NoError(t, dummyEngine.DestroyStep(ctx, step, workflowUUID)) assert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID)) }) t.Run("step tail error", func(t *testing.T) { step := &types.Step{ Name: "dummy", UUID: "SID_2", Environment: map[string]string{dummy.EnvKeyStepTailFail: "true"}, } workflowUUID := "WID_1" assert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID)) assert.NoError(t, dummyEngine.StartStep(ctx, step, workflowUUID)) _, err := dummyEngine.TailStep(ctx, step, workflowUUID) assert.Error(t, err) _, err = dummyEngine.WaitStep(ctx, step, workflowUUID) assert.NoError(t, err) assert.NoError(t, dummyEngine.DestroyStep(ctx, step, workflowUUID)) assert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID)) }) t.Run("step start fail", func(t *testing.T) { step := &types.Step{ Name: "dummy", UUID: "SID_2", Type: types.StepTypeService, Environment: map[string]string{dummy.EnvKeyStepType: "service", dummy.EnvKeyStepStartFail: "true"}, } workflowUUID := "WID_1" assert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, workflowUUID)) assert.Error(t, dummyEngine.StartStep(ctx, step, workflowUUID)) _, err := dummyEngine.TailStep(ctx, step, workflowUUID) assert.Error(t, err) state, err := dummyEngine.WaitStep(ctx, step, workflowUUID) assert.Error(t, err) assert.Error(t, state.Error) assert.EqualValues(t, 0, state.ExitCode) assert.Error(t, dummyEngine.DestroyStep(ctx, step, workflowUUID)) assert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, workflowUUID)) }) } func TestWaitStepCanceledBySleep(t *testing.T) { ctx, cancel := context.WithCancelCause(t.Context()) dummyEngine := dummy.New() _, err := dummyEngine.Load(ctx) require.NoError(t, err) const taskUUID = "cancel-task" assert.NoError(t, dummyEngine.SetupWorkflow(ctx, nil, taskUUID)) step := &types.Step{ Name: "slow-step", UUID: "slow-uuid", Type: types.StepTypeCommands, Environment: map[string]string{ dummy.EnvKeyStepSleep: "30s", }, } assert.NoError(t, dummyEngine.StartStep(ctx, step, taskUUID)) // Cancel before WaitStep — the pre-select ctx.Err() check handles this deterministically. cancel(nil) state, err := dummyEngine.WaitStep(ctx, step, taskUUID) assert.NoError(t, err, "WaitStep should not return an error on cancellation") assert.True(t, state.Exited, "step should be marked as exited") assert.Equal(t, dummy.ExitCodeCanceled, state.ExitCode, "canceled step must exit with code %d", dummy.ExitCodeCanceled) // DestroyStep must succeed even though the step was canceled mid-sleep. assert.NoError(t, dummyEngine.DestroyStep(ctx, step, taskUUID)) assert.NoError(t, dummyEngine.DestroyWorkflow(ctx, nil, taskUUID)) } ================================================ FILE: pipeline/backend/kubernetes/backend_options.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "github.com/go-viper/mapstructure/v2" kube_core_v1 "k8s.io/api/core/v1" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // BackendOptions defines all the advanced options for the kubernetes backend. type BackendOptions struct { Resources Resources `mapstructure:"resources"` RuntimeClassName *string `mapstructure:"runtimeClassName"` ServiceAccountName string `mapstructure:"serviceAccountName"` Labels map[string]string `mapstructure:"labels"` Annotations map[string]string `mapstructure:"annotations"` NodeSelector map[string]string `mapstructure:"nodeSelector"` Tolerations []Toleration `mapstructure:"tolerations"` Affinity *kube_core_v1.Affinity `mapstructure:"affinity"` SecurityContext *SecurityContext `mapstructure:"securityContext"` Secrets []SecretRef `mapstructure:"secrets"` } // Resources defines two maps for kubernetes resource definitions. type Resources struct { Requests map[string]string `mapstructure:"requests"` Limits map[string]string `mapstructure:"limits"` } // Toleration defines Kubernetes toleration. type Toleration struct { Key string `mapstructure:"key"` Operator TolerationOperator `mapstructure:"operator"` Value string `mapstructure:"value"` Effect TaintEffect `mapstructure:"effect"` TolerationSeconds *int64 `mapstructure:"tolerationSeconds"` } type TaintEffect string const ( TaintEffectNoSchedule TaintEffect = "NoSchedule" TaintEffectPreferNoSchedule TaintEffect = "PreferNoSchedule" TaintEffectNoExecute TaintEffect = "NoExecute" ) type TolerationOperator string const ( TolerationOpExists TolerationOperator = "Exists" TolerationOpEqual TolerationOperator = "Equal" ) type SecurityContext struct { Privileged *bool `mapstructure:"privileged"` RunAsNonRoot *bool `mapstructure:"runAsNonRoot"` RunAsUser *int64 `mapstructure:"runAsUser"` RunAsGroup *int64 `mapstructure:"runAsGroup"` FSGroup *int64 `mapstructure:"fsGroup"` FsGroupChangePolicy *kube_core_v1.PodFSGroupChangePolicy `mapstructure:"fsGroupChangePolicy"` SeccompProfile *SecProfile `mapstructure:"seccompProfile"` ApparmorProfile *SecProfile `mapstructure:"apparmorProfile"` AllowPrivilegeEscalation *bool `mapstructure:"allowPrivilegeEscalation"` Capabilities *Capabilities `mapstructure:"capabilities"` } type SecProfile struct { Type SecProfileType `mapstructure:"type"` LocalhostProfile string `mapstructure:"localhostProfile"` } type SecProfileType string type Capabilities struct { Drop []string `mapstructure:"drop"` } // SecretRef defines Kubernetes secret reference. type SecretRef struct { Name string `mapstructure:"name"` Key string `mapstructure:"key"` Target SecretTarget `mapstructure:"target"` } // SecretTarget defines secret mount target. type SecretTarget struct { Env string `mapstructure:"env"` File string `mapstructure:"file"` } const ( SecProfileTypeRuntimeDefault SecProfileType = "RuntimeDefault" SecProfileTypeLocalhost SecProfileType = "Localhost" ) func parseBackendOptions(step *backend_types.Step) (BackendOptions, error) { var result BackendOptions if step == nil || step.BackendOptions == nil { return result, nil } err := mapstructure.WeakDecode(step.BackendOptions[EngineName], &result) return result, err } ================================================ FILE: pipeline/backend/kubernetes/backend_options_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "testing" "github.com/stretchr/testify/assert" kube_core_v1 "k8s.io/api/core/v1" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func Test_parseBackendOptions(t *testing.T) { tests := []struct { name string step *backend_types.Step want BackendOptions wantErr bool }{ { name: "nil options", step: &backend_types.Step{BackendOptions: nil}, want: BackendOptions{}, }, { name: "empty options", step: &backend_types.Step{BackendOptions: map[string]any{}}, want: BackendOptions{}, }, { name: "full k8s options", step: &backend_types.Step{ BackendOptions: map[string]any{ "kubernetes": map[string]any{ "nodeSelector": map[string]string{"storage": "ssd"}, "serviceAccountName": "wp-svc-acc", "labels": map[string]string{"app": "test"}, "annotations": map[string]string{"apps.kubernetes.io/pod-index": "0"}, "tolerations": []map[string]any{ {"key": "net-port", "value": "100Mbit", "effect": TaintEffectNoSchedule}, }, "affinity": map[string]any{ "podAffinity": map[string]any{ "requiredDuringSchedulingIgnoredDuringExecution": []map[string]any{ { "labelSelector": map[string]any{}, "matchLabelKeys": []string{ "woodpecker-ci.org/task-uuid", }, "topologyKey": "kubernetes.io/hostname", }, }, }, }, "resources": map[string]any{ "requests": map[string]string{"memory": "128Mi", "cpu": "1000m"}, "limits": map[string]string{"memory": "256Mi", "cpu": "2"}, }, "securityContext": map[string]any{ "privileged": newBool(true), "runAsNonRoot": newBool(true), "runAsUser": newInt64(101), "runAsGroup": newInt64(101), "fsGroup": newInt64(101), "seccompProfile": map[string]any{ "type": "Localhost", "localhostProfile": "profiles/audit.json", }, "apparmorProfile": map[string]any{ "type": "Localhost", "localhostProfile": "k8s-apparmor-example-deny-write", }, }, "secrets": []map[string]any{ { "name": "aws", "key": "access-key", "target": map[string]any{ "env": "AWS_SECRET_ACCESS_KEY", }, }, { "name": "reg-cred", "key": ".dockerconfigjson", "target": map[string]any{ "file": "~/.docker/config.json", }, }, }, }, }, }, want: BackendOptions{ NodeSelector: map[string]string{"storage": "ssd"}, ServiceAccountName: "wp-svc-acc", Labels: map[string]string{"app": "test"}, Annotations: map[string]string{"apps.kubernetes.io/pod-index": "0"}, Tolerations: []Toleration{{Key: "net-port", Value: "100Mbit", Effect: TaintEffectNoSchedule}}, Affinity: &kube_core_v1.Affinity{ PodAffinity: &kube_core_v1.PodAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: []kube_core_v1.PodAffinityTerm{ { LabelSelector: &kube_meta_v1.LabelSelector{}, MatchLabelKeys: []string{ "woodpecker-ci.org/task-uuid", }, TopologyKey: "kubernetes.io/hostname", }, }, }, }, Resources: Resources{ Requests: map[string]string{"memory": "128Mi", "cpu": "1000m"}, Limits: map[string]string{"memory": "256Mi", "cpu": "2"}, }, SecurityContext: &SecurityContext{ Privileged: newBool(true), RunAsNonRoot: newBool(true), RunAsUser: newInt64(101), RunAsGroup: newInt64(101), FSGroup: newInt64(101), SeccompProfile: &SecProfile{ Type: "Localhost", LocalhostProfile: "profiles/audit.json", }, ApparmorProfile: &SecProfile{ Type: "Localhost", LocalhostProfile: "k8s-apparmor-example-deny-write", }, }, Secrets: []SecretRef{ { Name: "aws", Key: "access-key", Target: SecretTarget{Env: "AWS_SECRET_ACCESS_KEY"}, }, { Name: "reg-cred", Key: ".dockerconfigjson", Target: SecretTarget{File: "~/.docker/config.json"}, }, }, }, }, { name: "number options", step: &backend_types.Step{BackendOptions: map[string]any{ "kubernetes": map[string]any{ "resources": map[string]any{ "requests": map[string]int{"memory": 128, "cpu": 1000}, "limits": map[string]int{"memory": 256, "cpu": 2}, }, }, }}, want: BackendOptions{ Resources: Resources{ Requests: map[string]string{"memory": "128", "cpu": "1000"}, Limits: map[string]string{"memory": "256", "cpu": "2"}, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseBackendOptions(tt.step) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: pipeline/backend/kubernetes/flags.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "github.com/urfave/cli/v3" ) var Flags = []cli.Flag{ &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_NAMESPACE"), Name: "backend-k8s-namespace", Usage: "backend k8s namespace, if used with WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION, this will be the prefix for the namespace appended with the organization name.", Value: "woodpecker", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_NAMESPACE_PER_ORGANIZATION"), Name: "backend-k8s-namespace-per-org", Usage: "Whether to enable namespace segregation per organization feature. When enabled, Woodpecker will create the Kubernetes resources to separated Kubernetes namespaces per Woodpecker organization.", Value: false, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_VOLUME_SIZE"), Name: "backend-k8s-volume-size", Usage: "backend k8s volume size (default 10G)", Value: "10G", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_STORAGE_CLASS"), Name: "backend-k8s-storage-class", Usage: "backend k8s storage class", Value: "", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_STORAGE_RWX"), Name: "backend-k8s-storage-rwx", Usage: "backend k8s storage access mode, should ReadWriteMany (RWX) instead of ReadWriteOnce (RWO) be used? (default: true)", Value: true, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_LABELS"), Name: "backend-k8s-pod-labels", Usage: "backend k8s additional Agent-wide worker pod labels", Value: "", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_LABELS_ALLOW_FROM_STEP"), Name: "backend-k8s-pod-labels-allow-from-step", Usage: "whether to allow using labels from step's backend options", Value: false, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS"), Name: "backend-k8s-pod-annotations", Usage: "backend k8s additional Agent-wide worker pod annotations", Value: "", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_NODE_SELECTOR"), Name: "backend-k8s-pod-node-selector", Usage: "backend k8s Agent-wide worker pod node selector", Value: "", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_TOLERATIONS"), Name: "backend-k8s-pod-tolerations", Usage: "backend k8s Agent-wide worker pod tolerations", Value: "", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_ANNOTATIONS_ALLOW_FROM_STEP"), Name: "backend-k8s-pod-annotations-allow-from-step", Usage: "whether to allow using annotations from step's backend options", Value: false, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_TOLERATIONS_ALLOW_FROM_STEP"), Name: "backend-k8s-pod-tolerations-allow-from-step", Usage: "whether to allow using tolerations from step's backend options", Value: true, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_AFFINITY"), Name: "backend-k8s-pod-affinity", Usage: "backend k8s Agent-wide worker pod affinity, in YAML format", Value: "", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP"), Name: "backend-k8s-pod-affinity-allow-from-step", Usage: "whether to allow using affinity from step's backend options", Value: false, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_SECCTX_NONROOT"), // cspell:words secctx nonroot Name: "backend-k8s-secctx-nonroot", Usage: "`run as non root` Kubernetes security context option", }, &cli.StringSliceFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES"), Name: "backend-k8s-pod-image-pull-secret-names", Usage: "backend k8s pull secret names for private registries", Config: cli.StringConfig{ TrimSpace: true, }, }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_ALLOW_NATIVE_SECRETS"), Name: "backend-k8s-allow-native-secrets", Usage: "whether to allow existing Kubernetes secrets to be referenced from steps", Value: false, }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_PRIORITY_CLASS"), Name: "backend-k8s-priority-class", Usage: "which kubernetes priority class to assign to created job pods", Value: "", }, &cli.Int64Flag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_K8S_STOP_TIMEOUT"), Name: "backend-k8s-stop-timeout", Usage: "seconds Woodpecker waits for pods to stop gracefully before forcefully killing them", Value: 20, }, } ================================================ FILE: pipeline/backend/kubernetes/kubernetes.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "errors" "fmt" "io" "maps" "os" "runtime" "slices" "strconv" "strings" "sync" "time" "github.com/cenkalti/backoff/v5" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" kube_core_v1 "k8s.io/api/core/v1" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // To authenticate to GCP K8s clusters "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "sigs.k8s.io/yaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) const ( EngineName = "kubernetes" // TODO: 5 seconds is against best practice, k3s didn't work otherwise defaultResyncDuration = 5 * time.Second maxRetryDuration = 1 * time.Minute ) type kube struct { client kubernetes.Interface config *config goos string } type config struct { Namespace string EnableNamespacePerOrg bool StorageClass string VolumeSize string StorageRwx bool PodLabels map[string]string PodLabelsAllowFromStep bool PodAnnotations map[string]string PodAnnotationsAllowFromStep bool PodNodeSelector map[string]string PodTolerationsAllowFromStep bool PodTolerations []Toleration PodAffinity *kube_core_v1.Affinity PodAffinityAllowFromStep bool ImagePullSecretNames []string SecurityContext SecurityContextConfig NativeSecretsAllowFromStep bool PriorityClassName string StopTimeout int64 } func (c *config) GetNamespace(orgID int64) string { if c.EnableNamespacePerOrg { return strings.ToLower(fmt.Sprintf("%s-%s", c.Namespace, strconv.FormatInt(orgID, 10))) } return c.Namespace } type SecurityContextConfig struct { RunAsNonRoot bool FSGroup *int64 } func (c *config) newDefaultDeleteOptions() kube_meta_v1.DeleteOptions { propagationPolicy := kube_meta_v1.DeletePropagationBackground return kube_meta_v1.DeleteOptions{ GracePeriodSeconds: &c.StopTimeout, PropagationPolicy: &propagationPolicy, } } func configFromCliContext(ctx context.Context) (*config, error) { if ctx != nil { if c, ok := ctx.Value(types.CliCommand).(*cli.Command); ok { config := config{ Namespace: c.String("backend-k8s-namespace"), EnableNamespacePerOrg: c.Bool("backend-k8s-namespace-per-org"), StorageClass: c.String("backend-k8s-storage-class"), VolumeSize: c.String("backend-k8s-volume-size"), StorageRwx: c.Bool("backend-k8s-storage-rwx"), PriorityClassName: c.String("backend-k8s-priority-class"), PodLabels: make(map[string]string), // just init empty map to prevent nil panic PodLabelsAllowFromStep: c.Bool("backend-k8s-pod-labels-allow-from-step"), PodAnnotations: make(map[string]string), // just init empty map to prevent nil panic PodAnnotationsAllowFromStep: c.Bool("backend-k8s-pod-annotations-allow-from-step"), PodTolerationsAllowFromStep: c.Bool("backend-k8s-pod-tolerations-allow-from-step"), PodNodeSelector: make(map[string]string), // just init empty map to prevent nil panic PodAffinityAllowFromStep: c.Bool("backend-k8s-pod-affinity-allow-from-step"), ImagePullSecretNames: c.StringSlice("backend-k8s-pod-image-pull-secret-names"), SecurityContext: SecurityContextConfig{ RunAsNonRoot: c.Bool("backend-k8s-secctx-nonroot"), // cspell:words secctx nonroot FSGroup: newInt64(defaultFSGroup), }, NativeSecretsAllowFromStep: c.Bool("backend-k8s-allow-native-secrets"), StopTimeout: c.Int64("backend-k8s-stop-timeout"), } // Unmarshal label and annotation settings here to ensure they're valid on startup if labels := c.String("backend-k8s-pod-labels"); labels != "" { if err := yaml.Unmarshal([]byte(labels), &config.PodLabels); err != nil { log.Error().Err(err).Msgf("could not unmarshal pod labels '%s'", c.String("backend-k8s-pod-labels")) return nil, err } } if annotations := c.String("backend-k8s-pod-annotations"); annotations != "" { if err := yaml.Unmarshal([]byte(c.String("backend-k8s-pod-annotations")), &config.PodAnnotations); err != nil { log.Error().Err(err).Msgf("could not unmarshal pod annotations '%s'", c.String("backend-k8s-pod-annotations")) return nil, err } } if nodeSelector := c.String("backend-k8s-pod-node-selector"); nodeSelector != "" { if err := yaml.Unmarshal([]byte(nodeSelector), &config.PodNodeSelector); err != nil { log.Error().Err(err).Msgf("could not unmarshal pod node selector '%s'", nodeSelector) return nil, err } } if podTolerations := c.String("backend-k8s-pod-tolerations"); podTolerations != "" { if err := yaml.Unmarshal([]byte(podTolerations), &config.PodTolerations); err != nil { log.Error().Err(err).Msgf("could not unmarshal pod tolerations '%s'", podTolerations) return nil, err } } if podAffinity := c.String("backend-k8s-pod-affinity"); podAffinity != "" { if err := yaml.Unmarshal([]byte(podAffinity), &config.PodAffinity); err != nil { log.Error().Err(err).Msgf("could not unmarshal pod affinity '%s'", podAffinity) return nil, err } } return &config, nil } } return nil, types.ErrNoCliContextFound } // New returns a new Kubernetes Backend. func New() types.Backend { return &kube{} } func (e *kube) Name() string { return EngineName } func (e *kube) IsAvailable(context.Context) bool { host := os.Getenv("KUBERNETES_SERVICE_HOST") return len(host) > 0 } func (e *kube) Flags() []cli.Flag { return Flags } func (e *kube) Load(ctx context.Context) (*types.BackendInfo, error) { config, err := configFromCliContext(ctx) if err != nil { return nil, err } e.config = config var kubeClient kubernetes.Interface _, err = rest.InClusterConfig() if err != nil { kubeClient, err = getClientOutOfCluster() } else { kubeClient, err = getClientInsideOfCluster() } if err != nil { return nil, err } e.client = kubeClient // TODO(2693): use info resp of kubeClient to define platform var e.goos = runtime.GOOS return &types.BackendInfo{ Platform: runtime.GOOS + "/" + runtime.GOARCH, }, nil } func (e *kube) getConfig() *config { if e.config == nil { return nil } c := *e.config c.PodLabels = maps.Clone(e.config.PodLabels) c.PodAnnotations = maps.Clone(e.config.PodAnnotations) c.PodNodeSelector = maps.Clone(e.config.PodNodeSelector) c.ImagePullSecretNames = slices.Clone(e.config.ImagePullSecretNames) return &c } // SetupWorkflow sets up the pipeline environment. func (e *kube) SetupWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("Setting up Kubernetes primitives") namespace := e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID) if e.config.EnableNamespacePerOrg { log.Trace().Str("taskUUID", taskUUID).Msgf("Ensure organization namespace: %s", namespace) err := mkNamespace(ctx, e.client.CoreV1().Namespaces(), namespace) if err != nil { return err } } log.Trace().Str("taskUUID", taskUUID).Msgf("Creating workflow volume") _, err := startVolume(ctx, e, conf.Volume, namespace) if err != nil { return err } log.Trace().Str("taskUUID", taskUUID).Msgf("Creating workflow headless service") _, err = startHeadlessService(ctx, e, namespace, taskUUID) if err != nil { return err } return nil } // StartStep starts the pipeline step. func (e *kube) StartStep(ctx context.Context, step *types.Step, taskUUID string) error { options, err := parseBackendOptions(step) if err != nil { log.Error().Err(err).Msg("could not parse backend options") } if needsRegistrySecret(step) { err = startRegistrySecret(ctx, e, step) if err != nil { return err } } if needsStepSecret(step) { err = startStepSecret(ctx, e, step) if err != nil { return err } } log.Trace().Str("taskUUID", taskUUID).Msgf("starting step: %s", step.Name) _, err = startPod(ctx, e, step, options, taskUUID) return err } // WaitStep waits for the pipeline step to complete and returns // the completion results. func (e *kube) WaitStep(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error) { podName, err := stepToPodName(step) if err != nil { return nil, err } log.Trace().Str("taskUUID", taskUUID).Msgf("waiting for pod: %s", podName) finished := make(chan struct{}) var finishedOnce sync.Once podUpdated := func(_, newPod any) { pod, ok := newPod.(*kube_core_v1.Pod) if !ok { log.Error().Msgf("could not parse pod: %v", newPod) return } if pod.Name == podName { if isImagePullBackOffState(pod) || isInvalidImageName(pod) { finishedOnce.Do(func() { close(finished) }) } switch pod.Status.Phase { case kube_core_v1.PodSucceeded, kube_core_v1.PodFailed, kube_core_v1.PodUnknown: finishedOnce.Do(func() { close(finished) }) } } } si := informers.NewSharedInformerFactoryWithOptions(e.client, defaultResyncDuration, informers.WithNamespace(e.config.GetNamespace(step.OrgID))) if _, err := si.Core().V1().Pods().Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ UpdateFunc: podUpdated, }, ); err != nil { return nil, err } stop := make(chan struct{}) si.Start(stop) defer close(stop) select { case <-finished: case <-ctx.Done(): return nil, ctx.Err() } pod, err := e.client.CoreV1().Pods(e.config.GetNamespace(step.OrgID)).Get(ctx, podName, kube_meta_v1.GetOptions{}) if err != nil { return nil, err } if isImagePullBackOffState(pod) || isInvalidImageName(pod) { return nil, fmt.Errorf("could not pull image for pod %s", podName) } if len(pod.Status.ContainerStatuses) == 0 { return nil, fmt.Errorf("no container statuses found for pod %s", podName) } cs := pod.Status.ContainerStatuses[0] if cs.State.Terminated == nil { err := fmt.Errorf("no terminated state found for container %s/%s", podName, cs.Name) log.Error().Str("taskUUID", taskUUID).Str("pod", podName).Str("container", cs.Name).Interface("state", cs.State).Msg(err.Error()) return nil, err } bs := &types.State{ ExitCode: int(cs.State.Terminated.ExitCode), Exited: true, OOMKilled: false, } return bs, nil } // TailStep tails the pipeline step logs. func (e *kube) TailStep(ctx context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error) { podName, err := stepToPodName(step) if err != nil { return nil, err } log.Trace().Str("taskUUID", taskUUID).Msgf("tail logs of pod: %s", podName) up := make(chan struct{}) var upOnce sync.Once podUpdated := func(_, newPod any) { pod, ok := newPod.(*kube_core_v1.Pod) if !ok { log.Error().Msgf("could not parse pod: %v", newPod) return } if pod.Name == podName { if isImagePullBackOffState(pod) || isInvalidImageName(pod) { upOnce.Do(func() { close(up) }) } switch pod.Status.Phase { case kube_core_v1.PodRunning, kube_core_v1.PodSucceeded, kube_core_v1.PodFailed: upOnce.Do(func() { close(up) }) } } } si := informers.NewSharedInformerFactoryWithOptions(e.client, defaultResyncDuration, informers.WithNamespace(e.config.GetNamespace(step.OrgID))) if _, err := si.Core().V1().Pods().Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ UpdateFunc: podUpdated, }, ); err != nil { return nil, err } stop := make(chan struct{}) si.Start(stop) defer close(stop) select { case <-up: case <-ctx.Done(): return nil, ctx.Err() } opts := &kube_core_v1.PodLogOptions{ Follow: true, Container: podName, } logs, err := backoff.Retry(ctx, func() (io.ReadCloser, error) { return e.client.CoreV1().RESTClient().Get(). Namespace(e.config.GetNamespace(step.OrgID)). Name(podName). Resource("pods"). SubResource("log"). VersionedParams(opts, scheme.ParameterCodec). Stream(ctx) }, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxElapsedTime(maxRetryDuration), backoff.WithNotify(func(err error, delay time.Duration) { log.Warn().Err(err).Str("pod", podName).Dur("backoff", delay).Msg("failed to open pod log stream, retrying with backoff") }), ) if err != nil { return nil, err } rc, wc := io.Pipe() go func() { defer logs.Close() defer wc.Close() _, err = io.Copy(wc, logs) if err != nil { return } }() return rc, nil } func (e *kube) DestroyStep(ctx context.Context, step *types.Step, taskUUID string) error { var errs []error log.Trace().Str("taskUUID", taskUUID).Msgf("Stopping step: %s", step.Name) if needsRegistrySecret(step) { err := stopRegistrySecret(ctx, e, step, e.config.newDefaultDeleteOptions()) if err != nil { errs = append(errs, err) } } if needsStepSecret(step) { err := stopStepSecret(ctx, e, step, e.config.newDefaultDeleteOptions()) if err != nil { errs = append(errs, err) } } err := stopPod(ctx, e, step, e.config.newDefaultDeleteOptions()) if err != nil { errs = append(errs, err) } return errors.Join(errs...) } // DestroyWorkflow destroys the pipeline environment. func (e *kube) DestroyWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msg("deleting Kubernetes primitives") for _, stage := range conf.Stages { for _, step := range stage.Steps { err := stopPod(ctx, e, step, e.config.newDefaultDeleteOptions()) if err != nil { return err } } } namespace := e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID) log.Trace().Str("taskUUID", taskUUID).Msgf("deleting workflow headless service") err := e.stopHeadlessService(ctx, e, namespace, taskUUID) if err != nil { return err } log.Trace().Str("taskUUID", taskUUID).Msgf("deleting workflow volume") err = stopVolume(ctx, e, conf.Volume, e.config.GetNamespace(conf.Stages[0].Steps[0].OrgID), e.config.newDefaultDeleteOptions()) if err != nil { return err } return nil } ================================================ FILE: pipeline/backend/kubernetes/kubernetes_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "fmt" "runtime" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" kube_core_v1 "k8s.io/api/core/v1" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestGettingConfig(t *testing.T) { engine := kube{ config: &config{ Namespace: "default", StorageClass: "hdd", VolumeSize: "1G", StorageRwx: false, PodLabels: map[string]string{"l1": "v1"}, PodAnnotations: map[string]string{"a1": "v1"}, ImagePullSecretNames: []string{"regcred"}, SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, }, } config := engine.getConfig() config.Namespace = "wp" config.StorageClass = "ssd" config.StorageRwx = true config.PodLabels = nil config.PodAnnotations["a2"] = "v2" config.ImagePullSecretNames = append(config.ImagePullSecretNames, "docker.io") config.SecurityContext.RunAsNonRoot = true assert.Equal(t, "default", engine.config.Namespace) assert.Equal(t, "hdd", engine.config.StorageClass) assert.Equal(t, "1G", engine.config.VolumeSize) assert.False(t, engine.config.StorageRwx) assert.Len(t, engine.config.PodLabels, 1) assert.Len(t, engine.config.PodAnnotations, 1) assert.Len(t, engine.config.ImagePullSecretNames, 1) assert.False(t, engine.config.SecurityContext.RunAsNonRoot) } func TestSetupWorkflow(t *testing.T) { namespace := "foo" volumeName := "volume-name" volumePath := volumeName + ":/woodpecker" networkName := "test-network" taskUUID := "11301" engine := kube{ config: &config{ Namespace: namespace, StorageClass: "hdd", VolumeSize: "1G", StorageRwx: false, PodLabels: map[string]string{"l1": "v1"}, PodAnnotations: map[string]string{"a1": "v1"}, ImagePullSecretNames: []string{"regcred"}, SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, }, client: fake.NewClientset(), } serviceWithPorts := types.Step{ OrgID: 42, Name: "service", UUID: "123", Type: types.StepTypeService, Volumes: []string{volumePath}, Networks: []types.Conn{{Name: networkName, Aliases: []string{"alias"}}}, Ports: []types.Port{ {Number: 8080, Protocol: "tcp"}, }, } conf := &types.Config{ Volume: volumePath, Network: networkName, Stages: []*types.Stage{ { Steps: []*types.Step{ &serviceWithPorts, { OrgID: 42, UUID: "234", Name: "service2", Type: types.StepTypeService, Volumes: []string{volumePath}, Networks: []types.Conn{{Name: networkName, Aliases: []string{"alias"}}}, }, }, }, { Steps: []*types.Step{ { OrgID: 42, UUID: "456", Name: "step-1", Volumes: []string{volumePath}, Networks: []types.Conn{{Name: networkName, Aliases: []string{"alias"}}}, }, }, }, }, } err := engine.SetupWorkflow(context.Background(), conf, taskUUID) assert.NoError(t, err, "SetupWorkflow should not error with minimal config and fake client") _, err = engine.client.CoreV1().PersistentVolumeClaims(namespace).Get(context.Background(), "volume-name", kube_meta_v1.GetOptions{}) assert.NoError(t, err, "persistent volume should be created during workflow setup") _, err = engine.client.CoreV1().Services(namespace).Get(context.Background(), "wp-hsvc-"+taskUUID, kube_meta_v1.GetOptions{}) assert.NoError(t, err, "headless service should be created during workflow setup") } func TestAffinityFromCliContext(t *testing.T) { t.Setenv("WOODPECKER_BACKEND_K8S_NAMESPACE", "") t.Setenv("WOODPECKER_BACKEND_K8S_POD_AFFINITY", `{ "podAffinity": { "requiredDuringSchedulingIgnoredDuringExecution": [ { "labelSelector": {}, "matchLabelKeys": [ "woodpecker-ci.org/task-uuid" ], "topologyKey": "kubernetes.io/hostname" } ] } }`) t.Setenv("WOODPECKER_BACKEND_K8S_POD_AFFINITY_ALLOW_FROM_STEP", "false") cmd := &cli.Command{ Flags: Flags, Action: func(ctx context.Context, c *cli.Command) error { ctx = context.WithValue(ctx, types.CliCommand, c) config, err := configFromCliContext(ctx) require.NoError(t, err) require.NotNil(t, config) assert.False(t, config.PodAffinityAllowFromStep) // Verify affinity was parsed require.NotNil(t, config.PodAffinity) require.NotNil(t, config.PodAffinity.PodAffinity) require.Len(t, config.PodAffinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution, 1) term := config.PodAffinity.PodAffinity.RequiredDuringSchedulingIgnoredDuringExecution[0] assert.Equal(t, "kubernetes.io/hostname", term.TopologyKey) assert.Equal(t, []string{"woodpecker-ci.org/task-uuid"}, term.MatchLabelKeys) return nil }, } err := cmd.Run(context.Background(), []string{"test"}) require.NoError(t, err) } func makeStep(uuid string) *types.Step { return &types.Step{ UUID: uuid, Name: "step-" + uuid, OrgID: 1, } } func makeEngine(client *fake.Clientset) *kube { return &kube{ client: client, config: &config{ Namespace: "test-ns", }, } } func createPod( t *testing.T, client *fake.Clientset, step *types.Step, namespace string, ) string { t.Helper() podName, err := stepToPodName(step) require.NoError(t, err) pod := &kube_core_v1.Pod{ ObjectMeta: kube_meta_v1.ObjectMeta{ Name: podName, Namespace: namespace, }, Status: kube_core_v1.PodStatus{ Phase: kube_core_v1.PodPending, }, } _, err = client.CoreV1().Pods(namespace).Create( context.Background(), pod, kube_meta_v1.CreateOptions{}, ) require.NoError(t, err) return podName } func TestWaitStepReturnsOnContextCancel(t *testing.T) { client := fake.NewClientset() engine := makeEngine(client) step := makeStep("ctx-cancel-01") namespace := "test-ns" createPod(t, client, step, namespace) ctx, cancel := context.WithCancelCause(context.Background()) type result struct { state *types.State err error } ch := make(chan result, 1) go func() { s, err := engine.WaitStep(ctx, step, "task-1") ch <- result{s, err} }() // Give the informer time to start and begin watching. time.Sleep(200 * time.Millisecond) cancel(nil) select { case r := <-ch: assert.Nil(t, r.state) assert.ErrorIs(t, r.err, context.Canceled) case <-time.After(3 * time.Second): t.Fatal("WaitStep did not return after context cancellation") } } func TestWaitStepNoGoroutineLeak(t *testing.T) { client := fake.NewClientset() engine := makeEngine(client) namespace := "test-ns" numSteps := 10 steps := make([]*types.Step, numSteps) for i := range numSteps { steps[i] = makeStep(fmt.Sprintf("leak-%02d", i)) createPod(t, client, steps[i], namespace) } runtime.GC() time.Sleep(100 * time.Millisecond) baselineGoroutines := runtime.NumGoroutine() var wg sync.WaitGroup for i := range numSteps { wg.Add(1) go func() { defer wg.Done() ctx, cancel := context.WithCancelCause(context.Background()) go func() { _, _ = engine.WaitStep(ctx, steps[i], fmt.Sprintf("task-%d", i)) }() time.Sleep(200 * time.Millisecond) cancel(nil) }() } wg.Wait() time.Sleep(1 * time.Second) afterCancelGoroutines := runtime.NumGoroutine() leaked := afterCancelGoroutines - baselineGoroutines assert.Less(t, leaked, numSteps, "goroutines leaked after canceling %d WaitStep calls: got %d leaked", numSteps, leaked) } ================================================ FILE: pipeline/backend/kubernetes/namespace.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "github.com/rs/zerolog/log" kube_core_v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type K8sNamespaceClient interface { Get(ctx context.Context, name string, opts kube_meta_v1.GetOptions) (*kube_core_v1.Namespace, error) Create(ctx context.Context, namespace *kube_core_v1.Namespace, opts kube_meta_v1.CreateOptions) (*kube_core_v1.Namespace, error) } func mkNamespace(ctx context.Context, client K8sNamespaceClient, namespace string) error { _, err := client.Get(ctx, namespace, kube_meta_v1.GetOptions{}) if err == nil { log.Trace().Str("namespace", namespace).Msg("Kubernetes namespace already exists") return nil } if !errors.IsNotFound(err) { log.Trace().Err(err).Str("namespace", namespace).Msg("failed to check Kubernetes namespace existence") return err } log.Trace().Str("namespace", namespace).Msg("creating Kubernetes namespace") _, err = client.Create(ctx, &kube_core_v1.Namespace{ ObjectMeta: kube_meta_v1.ObjectMeta{Name: namespace}, }, kube_meta_v1.CreateOptions{}) if err != nil { log.Error().Err(err).Str("namespace", namespace).Msg("failed to create Kubernetes namespace") return err } log.Trace().Str("namespace", namespace).Msg("Kubernetes namespace created successfully") return nil } ================================================ FILE: pipeline/backend/kubernetes/namespace_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "errors" "testing" "github.com/stretchr/testify/assert" kube_core_v1 "k8s.io/api/core/v1" kube_errors "k8s.io/apimachinery/pkg/api/errors" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) type mockNamespaceClient struct { getError error createError error getCalled bool createCalled bool createdNS *kube_core_v1.Namespace } func (m *mockNamespaceClient) Get(_ context.Context, name string, _ kube_meta_v1.GetOptions) (*kube_core_v1.Namespace, error) { m.getCalled = true if m.getError != nil { return nil, m.getError } return &kube_core_v1.Namespace{ ObjectMeta: kube_meta_v1.ObjectMeta{Name: name}, }, nil } func (m *mockNamespaceClient) Create(_ context.Context, ns *kube_core_v1.Namespace, _ kube_meta_v1.CreateOptions) (*kube_core_v1.Namespace, error) { m.createCalled = true m.createdNS = ns return ns, m.createError } func TestMkNamespace(t *testing.T) { tests := []struct { name string namespace string setupMock func(*mockNamespaceClient) expectError bool errorContains string expectGetCalled bool expectCreateCalled bool }{ { name: "should succeed when namespace already exists", namespace: "existing-namespace", setupMock: func(m *mockNamespaceClient) { m.getError = nil // namespace exists }, expectError: false, expectGetCalled: true, expectCreateCalled: false, }, { name: "should create namespace when it doesn't exist", namespace: "new-namespace", setupMock: func(m *mockNamespaceClient) { m.getError = kube_errors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, "new-namespace") m.createError = nil }, expectError: false, expectGetCalled: true, expectCreateCalled: true, }, { name: "should fail when Get namespace returns generic error", namespace: "error-namespace", setupMock: func(m *mockNamespaceClient) { m.getError = errors.New("api server unavailable") }, expectError: true, errorContains: "api server unavailable", expectGetCalled: true, expectCreateCalled: false, }, { name: "should fail when Create namespace returns error", namespace: "create-fail-namespace", setupMock: func(m *mockNamespaceClient) { m.getError = kube_errors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, "create-fail-namespace") m.createError = errors.New("insufficient permissions") }, expectError: true, errorContains: "insufficient permissions", expectGetCalled: true, expectCreateCalled: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := &mockNamespaceClient{} tt.setupMock(client) err := mkNamespace(t.Context(), client, tt.namespace) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { assert.NoError(t, err) } assert.Equal(t, tt.expectGetCalled, client.getCalled, "Get call expectation") assert.Equal(t, tt.expectCreateCalled, client.createCalled, "Create call expectation") if tt.expectCreateCalled && client.createCalled { assert.NotNil(t, client.createdNS, "Created namespace should not be nil") assert.Equal(t, tt.namespace, client.createdNS.Name, "Created namespace should have correct name") } }) } } ================================================ FILE: pipeline/backend/kubernetes/pod.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "fmt" "maps" "strings" "github.com/rs/zerolog/log" kube_core_v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/common" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) const ( // StepLabelLegacy is the legacy label name from before the introduction of the woodpecker-ci.org namespace. // This will be removed in the future. StepLabelLegacy = "step" StepLabel = "woodpecker-ci.org/step" TaskUUIDLabel = "woodpecker-ci.org/task-uuid" podPrefix = "wp-" defaultFSGroup int64 = 1000 initContainerImage = "busybox:stable-musl" ) func mkPod(step *types.Step, config *config, podName, goos string, options BackendOptions, taskUUID string) (*kube_core_v1.Pod, error) { var err error nsp := newNativeSecretsProcessor(config, options.Secrets) err = nsp.process() if err != nil { return nil, err } meta, err := podMeta(step, config, options, podName, taskUUID) if err != nil { return nil, err } spec, err := podSpec(step, config, options, nsp, taskUUID) if err != nil { return nil, err } container, err := podContainer(step, podName, goos, options, nsp) if err != nil { return nil, err } spec.Containers = append(spec.Containers, container) initContainer := podInitContainer(&spec, &container) if initContainer != nil { spec.InitContainers = append(spec.InitContainers, *initContainer) } pod := &kube_core_v1.Pod{ ObjectMeta: meta, Spec: spec, } return pod, nil } func stepToPodName(step *types.Step) (name string, err error) { if isService(step) { return serviceName(step) } return podName(step) } func podName(step *types.Step) (string, error) { return dnsName(podPrefix + step.UUID) } func podMeta(step *types.Step, config *config, options BackendOptions, podName, taskUUID string) (kube_meta_v1.ObjectMeta, error) { var err error meta := kube_meta_v1.ObjectMeta{ Name: podName, Namespace: config.GetNamespace(step.OrgID), Annotations: podAnnotations(config, options), } meta.Labels, err = podLabels(step, config, options, taskUUID) if err != nil { return meta, err } return meta, nil } func podLabels(step *types.Step, config *config, options BackendOptions, taskUUID string) (map[string]string, error) { var err error labels := make(map[string]string) for k, v := range step.WorkflowLabels { // Only copy user labels if allowed by agent config. // Internal labels are filtered on the server-side. if config.PodLabelsAllowFromStep || strings.HasPrefix(k, pipeline.InternalLabelPrefix) { labels[k], err = toDNSName(v) if err != nil { return labels, err } } } if len(options.Labels) > 0 { if config.PodLabelsAllowFromStep { log.Trace().Msgf("using labels from the backend options: %v", options.Labels) // TODO should we filter out label with internal prefix? maps.Copy(labels, options.Labels) } else { log.Debug().Msg("Pod labels were defined in backend options, but its using disallowed by instance configuration") } } if len(config.PodLabels) > 0 { log.Trace().Msgf("using labels from the configuration: %v", config.PodLabels) // TODO should we filter out label with internal prefix? maps.Copy(labels, config.PodLabels) } if isService(step) { labels[ServiceLabel], _ = serviceName(step) } labels[StepLabelLegacy], err = stepLabel(step) if err != nil { return labels, err } labels[StepLabel], err = stepLabel(step) if err != nil { return labels, err } if len(taskUUID) > 0 { labels[TaskUUIDLabel] = taskUUID } return labels, nil } func stepLabel(step *types.Step) (string, error) { return toDNSName(step.Name) } func podAnnotations(config *config, options BackendOptions) map[string]string { annotations := make(map[string]string) if len(options.Annotations) > 0 { if config.PodAnnotationsAllowFromStep { log.Trace().Msgf("using annotations from the backend options: %v", options.Annotations) maps.Copy(annotations, options.Annotations) } else { log.Debug().Msg("Pod annotations were defined in backend options, but its using disallowed by instance configuration ") } } if len(config.PodAnnotations) > 0 { log.Trace().Msgf("using annotations from the configuration: %v", config.PodAnnotations) maps.Copy(annotations, config.PodAnnotations) } return annotations } func podSpec(step *types.Step, config *config, options BackendOptions, nsp nativeSecretsProcessor, taskUUID string) (kube_core_v1.PodSpec, error) { subdomain, err := subdomain(taskUUID) if err != nil { return kube_core_v1.PodSpec{}, err } spec := kube_core_v1.PodSpec{ RestartPolicy: kube_core_v1.RestartPolicyNever, RuntimeClassName: options.RuntimeClassName, ServiceAccountName: options.ServiceAccountName, PriorityClassName: config.PriorityClassName, HostAliases: hostAliases(step.ExtraHosts), Hostname: getHostnameOrEmpty(step.Name), Subdomain: subdomain, DNSConfig: dnsConfig(config.GetNamespace(step.OrgID), subdomain), NodeSelector: nodeSelector(options.NodeSelector, config.PodNodeSelector, step.Environment["CI_SYSTEM_PLATFORM"]), Tolerations: tolerations(options.Tolerations), Affinity: affinity(options.Affinity, config.PodAffinity, config.PodAffinityAllowFromStep), SecurityContext: podSecurityContext(options.SecurityContext, config.SecurityContext, step.Privileged), } // If there are tolerations and they are allowed if config.PodTolerationsAllowFromStep && len(options.Tolerations) != 0 { spec.Tolerations = tolerations(options.Tolerations) } else { spec.Tolerations = tolerations(config.PodTolerations) } spec.Volumes, err = pvcVolumes(step.Volumes) if err != nil { return spec, err } if len(step.DNS) != 0 || len(step.DNSSearch) != 0 { spec.DNSConfig = &kube_core_v1.PodDNSConfig{} if len(step.DNS) != 0 { spec.DNSConfig.Nameservers = step.DNS } if len(step.DNSSearch) != 0 { spec.DNSConfig.Searches = step.DNSSearch } } log.Trace().Msgf("using the image pull secrets: %v", config.ImagePullSecretNames) spec.ImagePullSecrets = secretsReferences(config.ImagePullSecretNames) if needsRegistrySecret(step) { log.Trace().Msgf("using an image pull secret from registries") name, err := registrySecretName(step) if err != nil { return spec, err } spec.ImagePullSecrets = append(spec.ImagePullSecrets, secretReference(name)) } spec.Volumes = append(spec.Volumes, nsp.volumes...) return spec, nil } func podContainer(step *types.Step, podName, goos string, options BackendOptions, nsp nativeSecretsProcessor) (kube_core_v1.Container, error) { var err error container := kube_core_v1.Container{ Name: podName, Image: step.Image, WorkingDir: step.WorkingDir, Ports: containerPorts(step.Ports), SecurityContext: containerSecurityContext(options.SecurityContext, step.Privileged), } if step.Pull { container.ImagePullPolicy = kube_core_v1.PullAlways } if len(step.Commands) > 0 { scriptEnv, command := common.GenerateContainerConf(step.Commands, goos, step.WorkingDir) container.Command = command maps.Copy(step.Environment, scriptEnv) // step.WorkingDir will be respected by the generated script container.WorkingDir = step.WorkspaceBase } if len(step.Entrypoint) > 0 { container.Command = step.Entrypoint } stepSecret, err := stepSecretName(step) if err != nil { return container, err } // filter environment variables to non-secrets and secrets, refer secrets from step secrets envs, secs := filterSecrets(step.Environment, step.SecretMapping) envsFromSecrets := mapToEnvVarsFromStepSecrets(secs, stepSecret) container.Env = append(mapToEnvVars(envs), envsFromSecrets...) container.Resources, err = resourceRequirements(options.Resources) if err != nil { return container, err } container.VolumeMounts, err = volumeMounts(step.Volumes) if err != nil { return container, err } container.EnvFrom = append(container.EnvFrom, nsp.envFromSources...) container.Env = append(container.Env, nsp.envVars...) container.VolumeMounts = append(container.VolumeMounts, nsp.mounts...) return container, nil } // podInitContainer determines whether an init container is required to prepare the // main step container's working directory with the correct permissions. // If it is required, it returns the init container spec, otherwise it returns an empty container spec. func podInitContainer(podSpec *kube_core_v1.PodSpec, container *kube_core_v1.Container) *kube_core_v1.Container { // if pod is running as root, we don't need an init container to precreate the workingDir // since kubelet already precreates it (as root:root) if podSpec.SecurityContext == nil || podSpec.SecurityContext.RunAsUser == nil || *podSpec.SecurityContext.RunAsUser == 0 { return nil } volumeMounts := []kube_core_v1.VolumeMount{} for _, mount := range container.VolumeMounts { // we only add volume mounts to the init container if the workingDir is under the mount path // otherwise the init container won't have permission to create the workingDir // when workingDir is exactly the same as mountPath, permissions are already handled by the FsGroupChangePolicy if strings.HasPrefix(container.WorkingDir, mount.MountPath+"/") { volumeMounts = append(volumeMounts, mount) } } // if workingDir is not covered by any volume mount, we don't need an init container to precreate it if len(volumeMounts) == 0 { return nil } return &kube_core_v1.Container{ Name: "init-" + container.Name, Image: initContainerImage, ImagePullPolicy: kube_core_v1.PullAlways, Args: []string{"mkdir", "-p", container.WorkingDir}, SecurityContext: &kube_core_v1.SecurityContext{ Capabilities: &kube_core_v1.Capabilities{ Drop: []kube_core_v1.Capability{"ALL"}, }, AllowPrivilegeEscalation: newBool(false), }, Resources: kube_core_v1.ResourceRequirements{ Requests: kube_core_v1.ResourceList{ kube_core_v1.ResourceCPU: resource.MustParse("5m"), kube_core_v1.ResourceMemory: resource.MustParse("5Mi"), }, Limits: kube_core_v1.ResourceList{ kube_core_v1.ResourceCPU: resource.MustParse("5m"), kube_core_v1.ResourceMemory: resource.MustParse("5Mi"), }, }, VolumeMounts: volumeMounts, } } func mapToEnvVarsFromStepSecrets(secs []string, stepSecretName string) []kube_core_v1.EnvVar { var ev []kube_core_v1.EnvVar for _, key := range secs { ev = append(ev, kube_core_v1.EnvVar{ Name: key, ValueFrom: &kube_core_v1.EnvVarSource{ SecretKeyRef: &kube_core_v1.SecretKeySelector{ LocalObjectReference: kube_core_v1.LocalObjectReference{ Name: stepSecretName, }, Key: key, }, }, }) } return ev } func filterSecrets(environment, secrets map[string]string) (map[string]string, []string) { ev := map[string]string{} var secs []string for k, v := range environment { if _, found := secrets[k]; found { secs = append(secs, k) } else { ev[k] = v } } return ev, secs } func pvcVolumes(volumes []string) ([]kube_core_v1.Volume, error) { var vols []kube_core_v1.Volume for _, v := range volumes { volumeName, err := volumeName(v) if err != nil { return nil, err } vols = append(vols, pvcVolume(volumeName)) } return vols, nil } func pvcVolume(name string) kube_core_v1.Volume { pvcSource := kube_core_v1.PersistentVolumeClaimVolumeSource{ ClaimName: name, ReadOnly: false, } return kube_core_v1.Volume{ Name: name, VolumeSource: kube_core_v1.VolumeSource{ PersistentVolumeClaim: &pvcSource, }, } } func volumeMounts(volumes []string) ([]kube_core_v1.VolumeMount, error) { var mounts []kube_core_v1.VolumeMount for _, v := range volumes { volumeName, err := volumeName(v) if err != nil { return nil, err } mount := volumeMount(volumeName, volumeMountPath(v)) mounts = append(mounts, mount) } return mounts, nil } func volumeMount(name, path string) kube_core_v1.VolumeMount { return kube_core_v1.VolumeMount{ Name: name, MountPath: path, } } func containerPorts(ports []types.Port) []kube_core_v1.ContainerPort { containerPorts := make([]kube_core_v1.ContainerPort, len(ports)) for i, port := range ports { containerPorts[i] = containerPort(port) } return containerPorts } func containerPort(port types.Port) kube_core_v1.ContainerPort { return kube_core_v1.ContainerPort{ ContainerPort: int32(port.Number), Protocol: kube_core_v1.Protocol(strings.ToUpper(port.Protocol)), } } // Here is the service IPs (placed in /etc/hosts in the Pod). func hostAliases(extraHosts []types.HostAlias) []kube_core_v1.HostAlias { var hostAliases []kube_core_v1.HostAlias for _, extraHost := range extraHosts { hostAlias := hostAlias(extraHost) hostAliases = append(hostAliases, hostAlias) } return hostAliases } func hostAlias(extraHost types.HostAlias) kube_core_v1.HostAlias { return kube_core_v1.HostAlias{ IP: extraHost.IP, Hostnames: []string{extraHost.Name}, } } func resourceRequirements(resources Resources) (kube_core_v1.ResourceRequirements, error) { var err error requirements := kube_core_v1.ResourceRequirements{} requirements.Requests, err = resourceList(resources.Requests) if err != nil { return requirements, err } requirements.Limits, err = resourceList(resources.Limits) if err != nil { return requirements, err } return requirements, nil } func resourceList(resources map[string]string) (kube_core_v1.ResourceList, error) { requestResources := kube_core_v1.ResourceList{} for key, val := range resources { resName := kube_core_v1.ResourceName(key) resVal, err := resource.ParseQuantity(val) if err != nil { return nil, fmt.Errorf("resource request '%s' quantity '%s': %w", key, val, err) } requestResources[resName] = resVal } return requestResources, nil } func nodeSelector(backendNodeSelector, configNodeSelector map[string]string, platform string) map[string]string { nodeSelector := make(map[string]string) if platform != "" { arch := strings.Split(platform, "/")[1] nodeSelector[kube_core_v1.LabelArchStable] = arch log.Trace().Msgf("using the node selector from the Agent's platform: %v", nodeSelector) } if len(configNodeSelector) > 0 { log.Trace().Msgf("appending labels to the node selector from the configuration: %v", configNodeSelector) maps.Copy(nodeSelector, configNodeSelector) } if len(backendNodeSelector) > 0 { log.Trace().Msgf("appending labels to the node selector from the backend options: %v", backendNodeSelector) maps.Copy(nodeSelector, backendNodeSelector) } return nodeSelector } func tolerations(backendTolerations []Toleration) []kube_core_v1.Toleration { var tolerations []kube_core_v1.Toleration if len(backendTolerations) > 0 { log.Trace().Msgf("tolerations that will be used in the backend options: %v", backendTolerations) for _, backendToleration := range backendTolerations { toleration := toleration(backendToleration) tolerations = append(tolerations, toleration) } } return tolerations } func toleration(backendToleration Toleration) kube_core_v1.Toleration { return kube_core_v1.Toleration{ Key: backendToleration.Key, Operator: kube_core_v1.TolerationOperator(backendToleration.Operator), Value: backendToleration.Value, Effect: kube_core_v1.TaintEffect(backendToleration.Effect), TolerationSeconds: backendToleration.TolerationSeconds, } } func affinity(stepAffinity, agentAffinity *kube_core_v1.Affinity, allowFromStep bool) *kube_core_v1.Affinity { if stepAffinity != nil { if allowFromStep { log.Trace().Msg("using affinity from step backend options") return stepAffinity } else { log.Debug().Msg("Step affinity is disallowed by instance configuration, ignoring it") } } if agentAffinity != nil { log.Trace().Msg("using affinity from agent configuration") return agentAffinity } log.Trace().Msg("no affinity configured") return nil } func podSecurityContext(sc *SecurityContext, secCtxConf SecurityContextConfig, stepPrivileged bool) *kube_core_v1.PodSecurityContext { var ( nonRoot *bool user *int64 group *int64 fsGroup *int64 fsGroupChangePolicy *kube_core_v1.PodFSGroupChangePolicy seccomp *kube_core_v1.SeccompProfile apparmor *kube_core_v1.AppArmorProfile ) if secCtxConf.RunAsNonRoot { nonRoot = newBool(true) } if secCtxConf.FSGroup != nil { fsGroup = secCtxConf.FSGroup } if sc != nil { // only allow to set user if its not root or step is privileged if sc.RunAsUser != nil && (*sc.RunAsUser != 0 || stepPrivileged) { user = sc.RunAsUser } // only allow to set group if its not root or step is privileged if sc.RunAsGroup != nil && (*sc.RunAsGroup != 0 || stepPrivileged) { group = sc.RunAsGroup } // only allow to set fsGroup if its not root or step is privileged if sc.FSGroup != nil && (*sc.FSGroup != 0 || stepPrivileged) { fsGroup = sc.FSGroup } // if unset, set fsGroup to 1000 by default to support non-root images if sc.FSGroup != nil { fsGroup = sc.FSGroup } // only allow to set nonRoot if it's not set globally already if nonRoot == nil && sc.RunAsNonRoot != nil { nonRoot = sc.RunAsNonRoot } seccomp = seccompProfile(sc.SeccompProfile) apparmor = apparmorProfile(sc.ApparmorProfile) fsGroupChangePolicy = sc.FsGroupChangePolicy } if nonRoot == nil && user == nil && group == nil && fsGroup == nil && seccomp == nil && apparmor == nil { return nil } securityContext := &kube_core_v1.PodSecurityContext{ RunAsNonRoot: nonRoot, RunAsUser: user, RunAsGroup: group, FSGroup: fsGroup, FSGroupChangePolicy: fsGroupChangePolicy, SeccompProfile: seccomp, AppArmorProfile: apparmor, } log.Trace().Msgf("pod security context that will be used: %v", securityContext) return securityContext } func seccompProfile(scp *SecProfile) *kube_core_v1.SeccompProfile { if scp == nil || len(scp.Type) == 0 { return nil } log.Trace().Msgf("using seccomp profile: %v", scp) seccompProfile := &kube_core_v1.SeccompProfile{ Type: kube_core_v1.SeccompProfileType(scp.Type), } if len(scp.LocalhostProfile) > 0 { seccompProfile.LocalhostProfile = &scp.LocalhostProfile } return seccompProfile } func apparmorProfile(scp *SecProfile) *kube_core_v1.AppArmorProfile { if scp == nil || len(scp.Type) == 0 { return nil } log.Trace().Msgf("using AppArmor profile: %v", scp) apparmorProfile := &kube_core_v1.AppArmorProfile{ Type: kube_core_v1.AppArmorProfileType(scp.Type), } if len(scp.LocalhostProfile) > 0 { apparmorProfile.LocalhostProfile = &scp.LocalhostProfile } return apparmorProfile } func containerCapabilities(capabilities *Capabilities) *kube_core_v1.Capabilities { if capabilities == nil || len(capabilities.Drop) == 0 { return nil } drop := make([]kube_core_v1.Capability, len(capabilities.Drop)) for i, c := range capabilities.Drop { drop[i] = kube_core_v1.Capability(c) } return &kube_core_v1.Capabilities{ Drop: drop, } } func containerSecurityContext(sc *SecurityContext, stepPrivileged bool) *kube_core_v1.SecurityContext { var ( privileged *bool allowPrivilegeEscalation *bool capabilities *kube_core_v1.Capabilities ) // A container may only run privileged when the step itself is privileged. // If the step is privileged, the container is privileged by default unless // explicitly disabled via securityContext.privileged=false. if stepPrivileged && (sc == nil || sc.Privileged == nil || *sc.Privileged) { privileged = newBool(true) } if sc != nil { // allowPrivilegeEscalation can only be set to false. if sc.AllowPrivilegeEscalation != nil && !*sc.AllowPrivilegeEscalation { allowPrivilegeEscalation = sc.AllowPrivilegeEscalation } capabilities = containerCapabilities(sc.Capabilities) } if privileged == nil && capabilities == nil && allowPrivilegeEscalation == nil { return nil } securityContext := &kube_core_v1.SecurityContext{ Privileged: privileged, AllowPrivilegeEscalation: allowPrivilegeEscalation, Capabilities: capabilities, } log.Trace().Msgf("container security context that will be used: %v", securityContext) return securityContext } func mapToEnvVars(m map[string]string) []kube_core_v1.EnvVar { var ev []kube_core_v1.EnvVar for k, v := range m { ev = append(ev, kube_core_v1.EnvVar{ Name: k, Value: v, }) } return ev } func dnsConfig(namespace, subdomain string) *kube_core_v1.PodDNSConfig { return &kube_core_v1.PodDNSConfig{ Searches: []string{fmt.Sprintf("%s.%s.svc.cluster.local", subdomain, namespace)}, } } func startPod(ctx context.Context, engine *kube, step *types.Step, options BackendOptions, taskUUID string) (*kube_core_v1.Pod, error) { podName, err := stepToPodName(step) if err != nil { return nil, err } engineConfig := engine.getConfig() pod, err := mkPod(step, engineConfig, podName, engine.goos, options, taskUUID) if err != nil { return nil, err } log.Trace().Msgf("creating pod: %s", pod.Name) return engine.client.CoreV1().Pods(engineConfig.GetNamespace(step.OrgID)).Create(ctx, pod, kube_meta_v1.CreateOptions{}) } func stopPod(ctx context.Context, engine *kube, step *types.Step, deleteOpts kube_meta_v1.DeleteOptions) error { podName, err := stepToPodName(step) if err != nil { return err } log.Trace().Str("name", podName).Msg("deleting pod") err = engine.client.CoreV1().Pods(engine.config.GetNamespace(step.OrgID)).Delete(ctx, podName, deleteOpts) if errors.IsNotFound(err) { // Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps. return nil } return err } ================================================ FILE: pipeline/backend/kubernetes/pod_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "encoding/json" "testing" "github.com/kinbiko/jsonassert" "github.com/stretchr/testify/assert" kube_core_v1 "k8s.io/api/core/v1" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) const taskUUID = "11301" func TestPodName(t *testing.T) { name, err := podName(&types.Step{UUID: "01he8bebctabr3kgk0qj36d2me-0"}) assert.NoError(t, err) assert.Equal(t, "wp-01he8bebctabr3kgk0qj36d2me-0", name) _, err = podName(&types.Step{UUID: "01he8bebctabr3kgk0qj36d2me\\0a"}) assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = podName(&types.Step{UUID: "01he8bebctabr3kgk0qj36d2me-0-services-0..woodpecker-runtime.svc.cluster.local"}) assert.ErrorIs(t, err, ErrDNSPatternInvalid) } func TestStepToPodName(t *testing.T) { name, err := stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "clone", Type: types.StepTypeClone}) assert.NoError(t, err) assert.EqualValues(t, "wp-01he8bebctabr3kg", name) name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "cache", Type: types.StepTypeCache}) assert.NoError(t, err) assert.EqualValues(t, "wp-01he8bebctabr3kg", name) name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "release", Type: types.StepTypePlugin}) assert.NoError(t, err) assert.EqualValues(t, "wp-01he8bebctabr3kg", name) name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "prepare-env", Type: types.StepTypeCommands}) assert.NoError(t, err) assert.EqualValues(t, "wp-01he8bebctabr3kg", name) name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "postgres", Type: types.StepTypeService, Ports: []types.Port{{Number: 5432}}}) assert.NoError(t, err) assert.EqualValues(t, "wp-svc-01he8bebctabr3kg-postgres", name) // Service name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "postgres", Detached: true, Type: types.StepTypeService, Ports: []types.Port{{Number: 5432}}}) assert.NoError(t, err) assert.EqualValues(t, "wp-svc-01he8bebctabr3kg-postgres", name) // Detached long running container name, err = stepToPodName(&types.Step{UUID: "01he8bebctabr3kg", Name: "long running", Detached: true}) assert.NoError(t, err) assert.EqualValues(t, "wp-01he8bebctabr3kg", name) } func TestPodMeta(t *testing.T) { meta, err := podMeta(&types.Step{ Name: "postgres", UUID: "01he8bebctabr3kg", Type: types.StepTypeService, Image: "postgres:16", WorkingDir: "/woodpecker/src", Environment: map[string]string{"CI": "woodpecker"}, Ports: []types.Port{{Number: 5432}}, }, &config{ Namespace: "woodpecker", }, BackendOptions{}, "wp-01he8bebctabr3kg-0", taskUUID) assert.NoError(t, err) assert.EqualValues(t, "wp-svc-01he8bebctabr3kg-postgres", meta.Labels[ServiceLabel]) assert.EqualValues(t, taskUUID, meta.Labels[TaskUUIDLabel]) // Service meta, err = podMeta(&types.Step{ Name: "postgres", UUID: "01he8bebctabr3kg", Detached: true, Type: types.StepTypeService, Image: "postgres:16", WorkingDir: "/woodpecker/src", Environment: map[string]string{"CI": "woodpecker"}, Ports: []types.Port{{Number: 5432}}, }, &config{ Namespace: "woodpecker", }, BackendOptions{}, "wp-01he8bebctabr3kg-0", taskUUID) assert.NoError(t, err) assert.EqualValues(t, "wp-svc-01he8bebctabr3kg-postgres", meta.Labels[ServiceLabel]) // Detached long running container meta, err = podMeta(&types.Step{ Name: "long running", UUID: "01he8bebctabr3kg", Detached: true, Image: "postgres:16", WorkingDir: "/woodpecker/src", Environment: map[string]string{"CI": "woodpecker"}, }, &config{ Namespace: "woodpecker", }, BackendOptions{}, "wp-01he8bebctabr3kg-0", taskUUID) assert.NoError(t, err) assert.EqualValues(t, "", meta.Labels[ServiceLabel]) } func TestStepLabel(t *testing.T) { name, err := stepLabel(&types.Step{Name: "Build image"}) assert.NoError(t, err) assert.EqualValues(t, "build-image", name) _, err = stepLabel(&types.Step{Name: ".build.image"}) assert.ErrorIs(t, err, ErrDNSPatternInvalid) } func TestPodHostnameSanitized(t *testing.T) { pod, err := mkPod(&types.Step{ Name: "Update repos", Image: "alpine:latest", UUID: "01he8bebctabr3kgk0qj36d2me-1", WorkingDir: "/woodpecker/src", Environment: map[string]string{}, }, &config{Namespace: "woodpecker"}, "wp-01he8bebctabr3kgk0qj36d2me-1", "linux/amd64", BackendOptions{}, taskUUID) assert.NoError(t, err) assert.Equal(t, "update-repos", pod.Spec.Hostname) } func TestTinyPod(t *testing.T) { const expected = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "build-via-gradle", "woodpecker-ci.org/step": "build-via-gradle", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "volumes": [ { "name": "workspace", "persistentVolumeClaim": { "claimName": "workspace" } } ], "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "gradle:8.4.0-jdk21", "command": [ "/bin/sh", "-c", "echo $CI_SCRIPT | base64 -d | /bin/sh -e" ], "env": [ "<>", { "name": "CI", "value": "woodpecker" }, { "name": "SHELL", "value": "/bin/sh" }, { "name": "CI_SCRIPT", "value": "CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3dvb2RwZWNrZXIvc3JjIgpjZCAiL3dvb2RwZWNrZXIvc3JjIgoKZWNobyArICdncmFkbGUgYnVpbGQnCmdyYWRsZSBidWlsZAo=" } ], "resources": {}, "volumeMounts": [ { "name": "workspace", "mountPath": "/woodpecker/src" } ] } ], "restartPolicy": "Never", "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "build-via-gradle" }, "status": {} }` pod, err := mkPod(&types.Step{ Name: "build-via-gradle", Image: "gradle:8.4.0-jdk21", UUID: "01he8bebctabr3kgk0qj36d2me-0", WorkingDir: "/woodpecker/src", Pull: false, Privileged: false, Commands: []string{"gradle build"}, Volumes: []string{"workspace:/woodpecker/src"}, Environment: map[string]string{"CI": "woodpecker"}, }, &config{ Namespace: "woodpecker", }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestFullPod(t *testing.T) { const expected = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "app": "test", "part-of": "woodpecker-ci", "step": "go-test", "woodpecker-ci.org/step": "go-test", "woodpecker-ci.org/task-uuid": "11301" }, "annotations": { "apps.kubernetes.io/pod-index": "0", "kubernetes.io/limit-ranger": "LimitRanger plugin set: cpu, memory request and limit for container" } }, "spec": { "volumes": [ { "name": "woodpecker-cache", "persistentVolumeClaim": { "claimName": "woodpecker-cache" } } ], "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "meltwater/drone-cache", "command": [ "/bin/sh", "-c" ], "ports": [ { "containerPort": 1234 }, { "containerPort": 2345, "protocol": "TCP" }, { "containerPort": 3456, "protocol": "UDP" } ], "env": [ "<>", { "name": "CGO", "value": "0" }, { "name": "CI_SCRIPT", "value": "CmlmIFsgLW4gIiRDSV9ORVRSQ19NQUNISU5FIiBdOyB0aGVuCmNhdCA8PEVPRiA+ICRIT01FLy5uZXRyYwptYWNoaW5lICRDSV9ORVRSQ19NQUNISU5FCmxvZ2luICRDSV9ORVRSQ19VU0VSTkFNRQpwYXNzd29yZCAkQ0lfTkVUUkNfUEFTU1dPUkQKRU9GCmNobW9kIDA2MDAgJEhPTUUvLm5ldHJjCmZpCnVuc2V0IENJX05FVFJDX1VTRVJOQU1FCnVuc2V0IENJX05FVFJDX1BBU1NXT1JECnVuc2V0IENJX1NDUklQVApta2RpciAtcCAiL3dvb2RwZWNrZXIvc3JjIgpjZCAiL3dvb2RwZWNrZXIvc3JjIgoKZWNobyArICdnbyBnZXQnCmdvIGdldAoKZWNobyArICdnbyB0ZXN0JwpnbyB0ZXN0Cg==" }, { "name": "SHELL", "value": "/bin/sh" } ], "resources": { "limits": { "cpu": "2", "memory": "256Mi" }, "requests": { "cpu": "1", "memory": "128Mi" } }, "volumeMounts": [ { "name": "woodpecker-cache", "mountPath": "/woodpecker/src/cache" } ], "imagePullPolicy": "Always", "securityContext": { "privileged": true, "allowPrivilegeEscalation": false, "capabilities": { "drop": ["ALL"] } } } ], "restartPolicy": "Never", "nodeSelector": { "storage": "ssd", "topology.kubernetes.io/region": "eu-central-1" }, "runtimeClassName": "runc", "serviceAccountName": "wp-svc-acc", "securityContext": { "runAsUser": 101, "runAsGroup": 101, "runAsNonRoot": true, "fsGroup": 101, "fsGroupChangePolicy": "OnRootMismatch", "appArmorProfile": { "type": "Localhost", "localhostProfile": "k8s-apparmor-example-deny-write" }, "seccompProfile": { "type": "Localhost", "localhostProfile": "profiles/audit.json" } }, "imagePullSecrets": [ { "name": "regcred" }, { "name": "another-pull-secret" }, { "name": "wp-01he8bebctabr3kgk0qj36d2me-0" } ], "tolerations": [ { "key": "net-port", "value": "100Mbit", "effect": "NoSchedule" } ], "hostAliases": [ { "ip": "1.1.1.1", "hostnames": [ "cloudflare" ] }, { "ip": "2606:4700:4700::64", "hostnames": [ "cf.v6" ] } ], "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"]}, "subdomain": "wp-hsvc-11301", "hostname": "go-test" }, "status": {} }` runtimeClass := "runc" hostAliases := []types.HostAlias{ {Name: "cloudflare", IP: "1.1.1.1"}, {Name: "cf.v6", IP: "2606:4700:4700::64"}, } ports := []types.Port{ {Number: 1234}, {Number: 2345, Protocol: "tcp"}, {Number: 3456, Protocol: "udp"}, } fsGroupChangePolicy := kube_core_v1.PodFSGroupChangePolicy("OnRootMismatch") secCtx := SecurityContext{ Privileged: newBool(true), RunAsNonRoot: newBool(true), RunAsUser: newInt64(101), RunAsGroup: newInt64(101), FSGroup: newInt64(101), FsGroupChangePolicy: &fsGroupChangePolicy, AllowPrivilegeEscalation: newBool(false), Capabilities: &Capabilities{ Drop: []string{"ALL"}, }, SeccompProfile: &SecProfile{ Type: "Localhost", LocalhostProfile: "profiles/audit.json", }, ApparmorProfile: &SecProfile{ Type: "Localhost", LocalhostProfile: "k8s-apparmor-example-deny-write", }, } pod, err := mkPod(&types.Step{ UUID: "01he8bebctabr3kgk0qj36d2me-0", Name: "go-test", Image: "meltwater/drone-cache", WorkingDir: "/woodpecker/src", Pull: true, Privileged: true, Commands: []string{"go get", "go test"}, Entrypoint: []string{"/bin/sh", "-c"}, Volumes: []string{"woodpecker-cache:/woodpecker/src/cache"}, Environment: map[string]string{"CGO": "0"}, ExtraHosts: hostAliases, Ports: ports, AuthConfig: types.Auth{ Username: "foo", Password: "bar", }, }, &config{ Namespace: "woodpecker", ImagePullSecretNames: []string{"regcred", "another-pull-secret"}, PodLabels: map[string]string{"app": "test"}, PodLabelsAllowFromStep: true, PodAnnotations: map[string]string{"apps.kubernetes.io/pod-index": "0"}, PodAnnotationsAllowFromStep: true, PodTolerationsAllowFromStep: true, PodNodeSelector: map[string]string{"topology.kubernetes.io/region": "eu-central-1"}, SecurityContext: SecurityContextConfig{RunAsNonRoot: false}, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ Labels: map[string]string{"part-of": "woodpecker-ci"}, Annotations: map[string]string{"kubernetes.io/limit-ranger": "LimitRanger plugin set: cpu, memory request and limit for container"}, NodeSelector: map[string]string{"storage": "ssd"}, RuntimeClassName: &runtimeClass, ServiceAccountName: "wp-svc-acc", Tolerations: []Toleration{{Key: "net-port", Value: "100Mbit", Effect: TaintEffectNoSchedule}}, Resources: Resources{ Requests: map[string]string{"memory": "128Mi", "cpu": "1000m"}, Limits: map[string]string{"memory": "256Mi", "cpu": "2"}, }, SecurityContext: &secCtx, }, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestPodPrivilege(t *testing.T) { createTestPod := func(stepPrivileged, globalRunAsRoot bool, secCtx SecurityContext) (*kube_core_v1.Pod, error) { return mkPod(&types.Step{ Name: "go-test", Image: "golang:1.16", UUID: "01he8bebctabr3kgk0qj36d2me-0", Privileged: stepPrivileged, }, &config{ Namespace: "woodpecker", SecurityContext: SecurityContextConfig{RunAsNonRoot: globalRunAsRoot}, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ SecurityContext: &secCtx, }, "11301") } // securty context is requesting user and group 101 (non-root) secCtx := SecurityContext{ RunAsUser: newInt64(101), RunAsGroup: newInt64(101), FSGroup: newInt64(101), } pod, err := createTestPod(false, false, secCtx) assert.NoError(t, err) assert.Equal(t, int64(101), *pod.Spec.SecurityContext.RunAsUser) assert.Equal(t, int64(101), *pod.Spec.SecurityContext.RunAsGroup) assert.Equal(t, int64(101), *pod.Spec.SecurityContext.FSGroup) // securty context is requesting root, but step is not privileged secCtx = SecurityContext{ RunAsUser: newInt64(0), RunAsGroup: newInt64(0), FSGroup: newInt64(0), } pod, err = createTestPod(false, false, secCtx) assert.NoError(t, err) assert.Equal(t, &kube_core_v1.PodSecurityContext{ SELinuxOptions: (*kube_core_v1.SELinuxOptions)(nil), WindowsOptions: (*kube_core_v1.WindowsSecurityContextOptions)(nil), RunAsUser: (*int64)(nil), RunAsGroup: (*int64)(nil), RunAsNonRoot: (*bool)(nil), SupplementalGroups: []int64(nil), SupplementalGroupsPolicy: (*kube_core_v1.SupplementalGroupsPolicy)(nil), FSGroup: newInt64(0), Sysctls: []kube_core_v1.Sysctl(nil), FSGroupChangePolicy: (*kube_core_v1.PodFSGroupChangePolicy)(nil), SeccompProfile: (*kube_core_v1.SeccompProfile)(nil), AppArmorProfile: (*kube_core_v1.AppArmorProfile)(nil), }, pod.Spec.SecurityContext) assert.Nil(t, pod.Spec.Containers[0].SecurityContext) // step is not privileged, but security context is requesting privileged secCtx = SecurityContext{ Privileged: newBool(true), } pod, err = createTestPod(false, false, secCtx) assert.NoError(t, err) assert.Nil(t, pod.Spec.SecurityContext) assert.Equal(t, (*kube_core_v1.PodSecurityContext)(nil), pod.Spec.SecurityContext) // step is privileged and security context is requesting privileged secCtx = SecurityContext{ Privileged: newBool(true), } pod, err = createTestPod(true, false, secCtx) assert.NoError(t, err) assert.True(t, *pod.Spec.Containers[0].SecurityContext.Privileged) // step is privileged and no security context is provided secCtx = SecurityContext{} pod, err = createTestPod(true, false, secCtx) assert.NoError(t, err) assert.True(t, *pod.Spec.Containers[0].SecurityContext.Privileged) // global runAsNonRoot is true and override is requested value by security context secCtx = SecurityContext{ RunAsNonRoot: newBool(false), } pod, err = createTestPod(false, true, secCtx) assert.NoError(t, err) assert.True(t, *pod.Spec.SecurityContext.RunAsNonRoot) // non-privileged step with allowPrivilegeEscalation=false: applied secCtx = SecurityContext{ AllowPrivilegeEscalation: newBool(false), } pod, err = createTestPod(false, false, secCtx) assert.NoError(t, err) assert.NotNil(t, pod.Spec.Containers[0].SecurityContext) assert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) // non-privileged step with allowPrivilegeEscalation=true: ignored secCtx = SecurityContext{ AllowPrivilegeEscalation: newBool(true), } pod, err = createTestPod(false, false, secCtx) assert.NoError(t, err) assert.Nil(t, pod.Spec.Containers[0].SecurityContext) // privileged step with allowPrivilegeEscalation=true: ignored secCtx = SecurityContext{ AllowPrivilegeEscalation: newBool(true), } pod, err = createTestPod(true, false, secCtx) assert.NoError(t, err) assert.Nil(t, pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) // non-privileged step with capabilities drop: applied secCtx = SecurityContext{ Capabilities: &Capabilities{Drop: []string{"ALL"}}, } pod, err = createTestPod(false, false, secCtx) assert.NoError(t, err) assert.NotNil(t, pod.Spec.Containers[0].SecurityContext) assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Capabilities.Add) assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) // non-privileged step with drop capabilities and allowPrivilegeEscalation=false: both applied secCtx = SecurityContext{ AllowPrivilegeEscalation: newBool(false), Capabilities: &Capabilities{Drop: []string{"ALL"}}, } pod, err = createTestPod(false, false, secCtx) assert.NoError(t, err) assert.NotNil(t, pod.Spec.Containers[0].SecurityContext) assert.Nil(t, pod.Spec.Containers[0].SecurityContext.Privileged) assert.False(t, *pod.Spec.Containers[0].SecurityContext.AllowPrivilegeEscalation) assert.Equal(t, []kube_core_v1.Capability{"ALL"}, pod.Spec.Containers[0].SecurityContext.Capabilities.Drop) } func TestScratchPod(t *testing.T) { const expected = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "curl-google", "woodpecker-ci.org/step": "curl-google", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "quay.io/curl/curl", "command": [ "/usr/bin/curl", "-v", "google.com" ], "resources": {} } ], "restartPolicy": "Never", "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "curl-google" }, "status": {} }` pod, err := mkPod(&types.Step{ Name: "curl-google", Image: "quay.io/curl/curl", UUID: "01he8bebctabr3kgk0qj36d2me-0", Entrypoint: []string{"/usr/bin/curl", "-v", "google.com"}, }, &config{ Namespace: "woodpecker", }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestSecrets(t *testing.T) { const expected = ` { "metadata": { "name": "wp-3kgk0qj36d2me01he8bebctabr-0", "namespace": "woodpecker", "labels": { "step": "test-secrets", "woodpecker-ci.org/step": "test-secrets", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "volumes": [ { "name": "workspace", "persistentVolumeClaim": { "claimName": "workspace" } }, { "name": "reg-cred", "secret": { "secretName": "reg-cred" } } ], "containers": [ { "name": "wp-3kgk0qj36d2me01he8bebctabr-0", "image": "alpine", "envFrom": [ { "secretRef": { "name": "ghcr-push-secret" } } ], "env": [ { "name": "CGO", "value": "0" }, { "name": "AWS_ACCESS_KEY_ID", "valueFrom": { "secretKeyRef": { "name": "aws-ecr", "key": "AWS_ACCESS_KEY_ID" } } }, { "name": "AWS_SECRET_ACCESS_KEY", "valueFrom": { "secretKeyRef": { "name": "aws-ecr", "key": "access-key" } } } ], "resources": {}, "volumeMounts": [ { "name": "workspace", "mountPath": "/woodpecker/src" }, { "name": "reg-cred", "mountPath": "~/.docker/config.json", "subPath": ".dockerconfigjson", "readOnly": true } ] } ], "restartPolicy": "Never", "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "test-secrets" }, "status": {} }` pod, err := mkPod(&types.Step{ Name: "test-secrets", Image: "alpine", UUID: "01he8bebctabr3kgk0qj36d2me-0", Environment: map[string]string{"CGO": "0"}, Volumes: []string{"workspace:/woodpecker/src"}, }, &config{ Namespace: "woodpecker", NativeSecretsAllowFromStep: true, }, "wp-3kgk0qj36d2me01he8bebctabr-0", "linux/amd64", BackendOptions{ Secrets: []SecretRef{ { Name: "ghcr-push-secret", }, { Name: "aws-ecr", Key: "AWS_ACCESS_KEY_ID", }, { Name: "aws-ecr", Key: "access-key", Target: SecretTarget{Env: "AWS_SECRET_ACCESS_KEY"}, }, { Name: "reg-cred", Key: ".dockerconfigjson", Target: SecretTarget{File: "~/.docker/config.json"}, }, }, }, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestPodTolerations(t *testing.T) { const expected = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "toleration-test", "woodpecker-ci.org/step": "toleration-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "tolerations": [ { "key": "foo", "value": "bar", "effect": "NoSchedule" }, { "key": "baz", "value": "qux", "effect": "NoExecute" } ], "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "toleration-test" }, "status": {} }` globalTolerations := []Toleration{ {Key: "foo", Value: "bar", Effect: TaintEffectNoSchedule}, {Key: "baz", Value: "qux", Effect: TaintEffectNoExecute}, } pod, err := mkPod(&types.Step{ Name: "toleration-test", Image: "alpine", UUID: "01he8bebctabr3kgk0qj36d2me-0", }, &config{ Namespace: "woodpecker", PodTolerations: globalTolerations, PodTolerationsAllowFromStep: false, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestPodTolerationsAllowFromStep(t *testing.T) { const expectedDisallow = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "toleration-test", "woodpecker-ci.org/step": "toleration-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "toleration-test" }, "status": {} }` const expectedAllow = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "toleration-test", "woodpecker-ci.org/step": "toleration-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "tolerations": [ { "key": "custom", "value": "value", "effect": "NoSchedule" } ], "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "toleration-test" }, "status": {} }` stepTolerations := []Toleration{ {Key: "custom", Value: "value", Effect: TaintEffectNoSchedule}, } step := &types.Step{ Name: "toleration-test", Image: "alpine", UUID: "01he8bebctabr3kgk0qj36d2me-0", } pod, err := mkPod(step, &config{ Namespace: "woodpecker", PodTolerationsAllowFromStep: false, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ Tolerations: stepTolerations, }, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expectedDisallow) pod, err = mkPod(step, &config{ Namespace: "woodpecker", PodTolerationsAllowFromStep: true, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ Tolerations: stepTolerations, }, taskUUID) assert.NoError(t, err) podJSON, err = json.Marshal(pod) assert.NoError(t, err) ja = jsonassert.New(t) ja.Assertf(string(podJSON), expectedAllow) } func TestStepSecret(t *testing.T) { const expected = `{ "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0-step-secret", "namespace": "woodpecker" }, "type": "Opaque", "stringData": { "VERY_SECRET": "secret_value" } }` secret, err := mkStepSecret(&types.Step{ UUID: "01he8bebctabr3kgk0qj36d2me-0", Name: "go-test", Image: "meltwater/drone-cache", SecretMapping: map[string]string{ "VERY_SECRET": "secret_value", }, }, &config{ Namespace: "woodpecker", }) assert.NoError(t, err) secretJSON, err := json.Marshal(secret) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(secretJSON), expected) } func TestPodAffinity(t *testing.T) { const expected = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "affinity-test", "woodpecker-ci.org/step": "affinity-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "affinity": { "podAffinity": { "requiredDuringSchedulingIgnoredDuringExecution": [ { "labelSelector": {}, "matchLabelKeys": ["woodpecker-ci.org/task-uuid"], "topologyKey": "kubernetes.io/hostname" } ] } }, "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "affinity-test" }, "status": {} }` agentAffinity := &kube_core_v1.Affinity{ PodAffinity: &kube_core_v1.PodAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: []kube_core_v1.PodAffinityTerm{ { LabelSelector: &kube_meta_v1.LabelSelector{}, MatchLabelKeys: []string{"woodpecker-ci.org/task-uuid"}, TopologyKey: "kubernetes.io/hostname", }, }, }, } pod, err := mkPod(&types.Step{ Name: "affinity-test", Image: "alpine", UUID: "01he8bebctabr3kgk0qj36d2me-0", }, &config{ Namespace: "woodpecker", PodAffinity: agentAffinity, PodAffinityAllowFromStep: false, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{}, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestPodAffinityAllowFromStep(t *testing.T) { const expectedDisallow = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "affinity-test", "woodpecker-ci.org/step": "affinity-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "affinity-test" }, "status": {} }` const expectedAllow = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "affinity-test", "woodpecker-ci.org/step": "affinity-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "affinity": { "podAntiAffinity": { "preferredDuringSchedulingIgnoredDuringExecution": [ { "weight": 100, "podAffinityTerm": { "labelSelector": { "matchLabels": { "app": "woodpecker" } }, "topologyKey": "kubernetes.io/hostname" } } ] } }, "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "affinity-test" }, "status": {} }` stepAffinity := &kube_core_v1.Affinity{ PodAntiAffinity: &kube_core_v1.PodAntiAffinity{ PreferredDuringSchedulingIgnoredDuringExecution: []kube_core_v1.WeightedPodAffinityTerm{ { Weight: 100, PodAffinityTerm: kube_core_v1.PodAffinityTerm{ LabelSelector: &kube_meta_v1.LabelSelector{ MatchLabels: map[string]string{ "app": "woodpecker", }, }, TopologyKey: "kubernetes.io/hostname", }, }, }, }, } step := &types.Step{ Name: "affinity-test", Image: "alpine", UUID: "01he8bebctabr3kgk0qj36d2me-0", } pod, err := mkPod(step, &config{ Namespace: "woodpecker", PodAffinity: nil, PodAffinityAllowFromStep: false, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ Affinity: stepAffinity, }, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expectedDisallow) pod, err = mkPod(step, &config{ Namespace: "woodpecker", PodAffinity: nil, PodAffinityAllowFromStep: true, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ Affinity: stepAffinity, }, taskUUID) assert.NoError(t, err) podJSON, err = json.Marshal(pod) assert.NoError(t, err) ja = jsonassert.New(t) ja.Assertf(string(podJSON), expectedAllow) } func TestPodAffinityStepOverridesAgent(t *testing.T) { const expected = ` { "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "affinity-test", "woodpecker-ci.org/step": "affinity-test", "woodpecker-ci.org/task-uuid": "11301" } }, "spec": { "containers": [ { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "image": "alpine", "resources": {} } ], "restartPolicy": "Never", "affinity": { "nodeAffinity": { "requiredDuringSchedulingIgnoredDuringExecution": { "nodeSelectorTerms": [ { "matchExpressions": [ { "key": "disk-type", "operator": "In", "values": ["ssd"] } ] } ] } } }, "dnsConfig": { "searches": ["wp-hsvc-11301.woodpecker.svc.cluster.local"] }, "subdomain": "wp-hsvc-11301", "hostname": "affinity-test" }, "status": {} }` agentAffinity := &kube_core_v1.Affinity{ NodeAffinity: &kube_core_v1.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &kube_core_v1.NodeSelector{ NodeSelectorTerms: []kube_core_v1.NodeSelectorTerm{ { MatchExpressions: []kube_core_v1.NodeSelectorRequirement{ { Key: "topology.kubernetes.io/zone", Operator: kube_core_v1.NodeSelectorOpIn, Values: []string{"eu-central-1a"}, }, }, }, }, }, }, } stepAffinity := &kube_core_v1.Affinity{ NodeAffinity: &kube_core_v1.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &kube_core_v1.NodeSelector{ NodeSelectorTerms: []kube_core_v1.NodeSelectorTerm{ { MatchExpressions: []kube_core_v1.NodeSelectorRequirement{ { Key: "disk-type", Operator: kube_core_v1.NodeSelectorOpIn, Values: []string{"ssd"}, }, }, }, }, }, }, } pod, err := mkPod(&types.Step{ Name: "affinity-test", Image: "alpine", UUID: "01he8bebctabr3kgk0qj36d2me-0", }, &config{ Namespace: "woodpecker", PodAffinity: agentAffinity, PodAffinityAllowFromStep: true, }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ Affinity: stepAffinity, }, taskUUID) assert.NoError(t, err) podJSON, err := json.Marshal(pod) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestInitContainer(t *testing.T) { const expected = ` { "name": "init-wp-01he8bebctabr3kgk0qj36d2me-0", "image": "busybox:stable-musl", "imagePullPolicy": "Always", "args": [ "mkdir", "-p", "/woodpecker/src/github.com/woodpecker-ci/woodpecker" ], "resources": { "requests": { "cpu": "5m", "memory": "5Mi" }, "limits": { "cpu": "5m", "memory": "5Mi" } }, "securityContext": { "allowPrivilegeEscalation": false, "capabilities": {"drop": ["ALL"]} }, "volumeMounts": [ { "name": "workspace", "mountPath": "/woodpecker/src" } ] }` pod, err := mkPod(&types.Step{ Name: "clone", Image: "docker.io/woodpeckerci/plugin-git", UUID: "01he8bebctabr3kgk0qj36d2me-0", WorkingDir: "/woodpecker/src/github.com/woodpecker-ci/woodpecker", Volumes: []string{"workspace:/woodpecker/src", "other:/other"}, }, &config{ Namespace: "woodpecker", }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", BackendOptions{ SecurityContext: &SecurityContext{ RunAsNonRoot: newBool(true), RunAsUser: newInt64(1000), }, }, taskUUID) assert.NoError(t, err) assert.NotNil(t, pod.Spec.InitContainers) assert.NotEmpty(t, pod.Spec.InitContainers) assert.Len(t, pod.Spec.InitContainers, 1) podJSON, err := json.Marshal(pod.Spec.InitContainers[0]) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(podJSON), expected) } func TestUnrequiredInitContainer(t *testing.T) { createTestPod := func(workingDir string, backendOpts BackendOptions) (*kube_core_v1.Pod, error) { return mkPod(&types.Step{ Name: "init-container-test", Image: "docker.io/woodpeckerci/plugin-git", UUID: "01he8bebctabr3kgk0qj36d2me-0", WorkingDir: workingDir, Volumes: []string{"workspace:/woodpecker/src"}, }, &config{ Namespace: "woodpecker", }, "wp-01he8bebctabr3kgk0qj36d2me-0", "linux/amd64", backendOpts, taskUUID) } // no security context (pod running as root), does not need init container pod, err := createTestPod("/woodpecker/src/github.com/woodpecker-ci/woodpecker", BackendOptions{}) assert.NoError(t, err) assert.Nil(t, pod.Spec.InitContainers) // explicit security context requesting root, does not need init container pod, err = createTestPod("/woodpecker/src/github.com/woodpecker-ci/woodpecker", BackendOptions{ SecurityContext: &SecurityContext{ RunAsNonRoot: newBool(false), RunAsUser: newInt64(0), }, }) assert.NoError(t, err) assert.Nil(t, pod.Spec.InitContainers) // working dir is outside of the workspace volume, does not need init container pod, err = createTestPod("/tmp", BackendOptions{ SecurityContext: &SecurityContext{ RunAsNonRoot: newBool(true), RunAsUser: newInt64(1000), }, }) assert.NoError(t, err) assert.Nil(t, pod.Spec.InitContainers) // workingDir is exactly the same as the workspace volume mount path, does not need init container pod, err = createTestPod("/woodpecker/src", BackendOptions{ SecurityContext: &SecurityContext{ RunAsNonRoot: newBool(true), RunAsUser: newInt64(1000), }, }) assert.NoError(t, err) assert.Nil(t, pod.Spec.InitContainers) } ================================================ FILE: pipeline/backend/kubernetes/secrets.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "encoding/json" "fmt" "strings" "github.com/distribution/reference" "github.com/docker/cli/cli/config/configfile" docker_config_types "github.com/docker/cli/cli/config/types" "github.com/rs/zerolog/log" kube_core_v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils" ) type nativeSecretsProcessor struct { config *config secrets []SecretRef envFromSources []kube_core_v1.EnvFromSource envVars []kube_core_v1.EnvVar volumes []kube_core_v1.Volume mounts []kube_core_v1.VolumeMount } func newNativeSecretsProcessor(config *config, secrets []SecretRef) nativeSecretsProcessor { return nativeSecretsProcessor{ config: config, secrets: secrets, } } func (nsp *nativeSecretsProcessor) isEnabled() bool { return nsp.config.NativeSecretsAllowFromStep } func (nsp *nativeSecretsProcessor) process() error { if len(nsp.secrets) > 0 { if !nsp.isEnabled() { log.Debug().Msg("Secret names were defined in backend options, but secret access is disallowed by instance configuration.") return nil } } else { return nil } for _, secret := range nsp.secrets { switch { case secret.isSimple(): simpleSecret, err := secret.toEnvFromSource() if err != nil { return err } nsp.envFromSources = append(nsp.envFromSources, simpleSecret) case secret.isAdvanced(): advancedSecret, err := secret.toEnvVar() if err != nil { return err } nsp.envVars = append(nsp.envVars, advancedSecret) case secret.isFile(): volume, err := secret.toVolume() if err != nil { return err } nsp.volumes = append(nsp.volumes, volume) mount, err := secret.toVolumeMount() if err != nil { return err } nsp.mounts = append(nsp.mounts, mount) } } return nil } func (sr SecretRef) isSimple() bool { return len(sr.Key) == 0 && len(sr.Target.Env) == 0 && !sr.isFile() } func (sr SecretRef) isAdvanced() bool { return (len(sr.Key) > 0 || len(sr.Target.Env) > 0) && !sr.isFile() } func (sr SecretRef) isFile() bool { return len(sr.Target.File) > 0 } func (sr SecretRef) toEnvFromSource() (kube_core_v1.EnvFromSource, error) { env := kube_core_v1.EnvFromSource{} if !sr.isSimple() { return env, fmt.Errorf("secret '%s' is not simple reference", sr.Name) } env = kube_core_v1.EnvFromSource{ SecretRef: &kube_core_v1.SecretEnvSource{ LocalObjectReference: secretReference(sr.Name), }, } return env, nil } func (sr SecretRef) toEnvVar() (kube_core_v1.EnvVar, error) { envVar := kube_core_v1.EnvVar{} if !sr.isAdvanced() { return envVar, fmt.Errorf("secret '%s' is not advanced reference", sr.Name) } envVar.ValueFrom = &kube_core_v1.EnvVarSource{ SecretKeyRef: &kube_core_v1.SecretKeySelector{ LocalObjectReference: secretReference(sr.Name), Key: sr.Key, }, } if len(sr.Target.Env) > 0 { envVar.Name = sr.Target.Env } else { envVar.Name = strings.ToUpper(sr.Key) } return envVar, nil } func (sr SecretRef) toVolume() (kube_core_v1.Volume, error) { var err error volume := kube_core_v1.Volume{} if !sr.isFile() { return volume, fmt.Errorf("secret '%s' is not file reference", sr.Name) } volume.Name, err = volumeName(sr.Name) if err != nil { return volume, err } volume.Secret = &kube_core_v1.SecretVolumeSource{ SecretName: sr.Name, } return volume, nil } func (sr SecretRef) toVolumeMount() (kube_core_v1.VolumeMount, error) { var err error mount := kube_core_v1.VolumeMount{ ReadOnly: true, } if !sr.isFile() { return mount, fmt.Errorf("secret '%s' is not file reference", sr.Name) } mount.Name, err = volumeName(sr.Name) if err != nil { return mount, err } mount.MountPath = sr.Target.File mount.SubPath = sr.Key return mount, nil } func secretsReferences(names []string) []kube_core_v1.LocalObjectReference { secretReferences := make([]kube_core_v1.LocalObjectReference, len(names)) for i, imagePullSecretName := range names { secretReferences[i] = secretReference(imagePullSecretName) } return secretReferences } func secretReference(name string) kube_core_v1.LocalObjectReference { return kube_core_v1.LocalObjectReference{ Name: name, } } func needsRegistrySecret(step *types.Step) bool { return step.AuthConfig.Username != "" && step.AuthConfig.Password != "" } func mkRegistrySecret(step *types.Step, config *config) (*kube_core_v1.Secret, error) { name, err := registrySecretName(step) if err != nil { return nil, err } labels, err := registrySecretLabels(step, config) if err != nil { return nil, err } named, err := utils.ParseNamed(step.Image) if err != nil { return nil, err } authConfig := configfile.ConfigFile{ AuthConfigs: map[string]docker_config_types.AuthConfig{ reference.Domain(named): { Username: step.AuthConfig.Username, Password: step.AuthConfig.Password, }, }, } configFileJSON, err := json.Marshal(authConfig) if err != nil { return nil, err } return &kube_core_v1.Secret{ ObjectMeta: kube_meta_v1.ObjectMeta{ Namespace: config.GetNamespace(step.OrgID), Name: name, Labels: labels, }, Type: kube_core_v1.SecretTypeDockerConfigJson, Data: map[string][]byte{ kube_core_v1.DockerConfigJsonKey: configFileJSON, }, }, nil } func registrySecretName(step *types.Step) (string, error) { return podName(step) } func registrySecretLabels(step *types.Step, config *config) (map[string]string, error) { var err error labels := make(map[string]string) for k, v := range step.WorkflowLabels { // Only copy user labels if allowed by agent config. // Internal labels are filtered on the server-side. if config.PodLabelsAllowFromStep || strings.HasPrefix(k, pipeline.InternalLabelPrefix) { labels[k], err = toDNSName(v) if err != nil { return labels, err } } } if step.Type == types.StepTypeService { labels[ServiceLabel], _ = serviceName(step) } labels[StepLabelLegacy], err = stepLabel(step) if err != nil { return labels, err } labels[StepLabel], err = stepLabel(step) if err != nil { return labels, err } return labels, nil } func startRegistrySecret(ctx context.Context, engine *kube, step *types.Step) error { secret, err := mkRegistrySecret(step, engine.config) if err != nil { return err } log.Trace().Msgf("creating secret: %s", secret.Name) _, err = engine.client.CoreV1().Secrets(engine.config.GetNamespace(step.OrgID)).Create(ctx, secret, kube_meta_v1.CreateOptions{}) if err != nil { return err } return nil } func stopRegistrySecret(ctx context.Context, engine *kube, step *types.Step, deleteOpts kube_meta_v1.DeleteOptions) error { name, err := registrySecretName(step) if err != nil { return err } log.Trace().Str("name", name).Msg("deleting secret") err = engine.client.CoreV1().Secrets(engine.config.GetNamespace(step.OrgID)).Delete(ctx, name, deleteOpts) if errors.IsNotFound(err) { return nil } return err } func needsStepSecret(step *types.Step) bool { return len(step.SecretMapping) > 0 } func startStepSecret(ctx context.Context, e *kube, step *types.Step) error { secret, err := mkStepSecret(step, e.config) if err != nil { return err } log.Trace().Msgf("creating secret: %s", secret.Name) _, err = e.client.CoreV1().Secrets(e.config.GetNamespace(step.OrgID)).Create(ctx, secret, kube_meta_v1.CreateOptions{}) if err != nil { return err } return nil } func mkStepSecret(step *types.Step, config *config) (*kube_core_v1.Secret, error) { name, err := stepSecretName(step) if err != nil { return nil, err } return &kube_core_v1.Secret{ ObjectMeta: kube_meta_v1.ObjectMeta{ Namespace: config.GetNamespace(step.OrgID), Name: name, }, Type: kube_core_v1.SecretTypeOpaque, StringData: step.SecretMapping, }, nil } func stepSecretName(step *types.Step) (string, error) { name, err := stepToPodName(step) if err != nil { return "", err } return fmt.Sprintf("%s-step-secret", name), nil } func stopStepSecret(ctx context.Context, engine *kube, step *types.Step, deleteOpts kube_meta_v1.DeleteOptions) error { name, err := stepSecretName(step) if err != nil { return err } log.Trace().Str("name", name).Msg("deleting secret") err = engine.client.CoreV1().Secrets(engine.config.GetNamespace(step.OrgID)).Delete(ctx, name, deleteOpts) if errors.IsNotFound(err) { return nil } return err } ================================================ FILE: pipeline/backend/kubernetes/secrets_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "encoding/json" "testing" "github.com/kinbiko/jsonassert" "github.com/stretchr/testify/assert" kube_core_v1 "k8s.io/api/core/v1" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestNativeSecretsEnabled(t *testing.T) { nsp := newNativeSecretsProcessor(&config{ NativeSecretsAllowFromStep: true, }, nil) assert.True(t, nsp.isEnabled()) } func TestNativeSecretsDisabled(t *testing.T) { nsp := newNativeSecretsProcessor(&config{ NativeSecretsAllowFromStep: false, }, []SecretRef{ { Name: "env-simple", }, { Name: "env-advanced", Key: "key", Target: SecretTarget{ Env: "ENV_VAR", }, }, { Name: "env-file", Key: "cert", Target: SecretTarget{ File: "/etc/ca/x3.cert", }, }, }) assert.False(t, nsp.isEnabled()) err := nsp.process() assert.NoError(t, err) assert.Empty(t, nsp.envFromSources) assert.Empty(t, nsp.envVars) assert.Empty(t, nsp.volumes) assert.Empty(t, nsp.mounts) } func TestSimpleSecret(t *testing.T) { nsp := newNativeSecretsProcessor(&config{ NativeSecretsAllowFromStep: true, }, []SecretRef{ { Name: "test-secret", }, }) err := nsp.process() assert.NoError(t, err) assert.Empty(t, nsp.envVars) assert.Empty(t, nsp.volumes) assert.Empty(t, nsp.mounts) assert.Equal(t, []kube_core_v1.EnvFromSource{ { SecretRef: &kube_core_v1.SecretEnvSource{ LocalObjectReference: kube_core_v1.LocalObjectReference{Name: "test-secret"}, }, }, }, nsp.envFromSources) } func TestSecretWithKey(t *testing.T) { nsp := newNativeSecretsProcessor(&config{ NativeSecretsAllowFromStep: true, }, []SecretRef{ { Name: "test-secret", Key: "access_key", }, }) err := nsp.process() assert.NoError(t, err) assert.Empty(t, nsp.envFromSources) assert.Empty(t, nsp.volumes) assert.Empty(t, nsp.mounts) assert.Equal(t, []kube_core_v1.EnvVar{ { Name: "ACCESS_KEY", ValueFrom: &kube_core_v1.EnvVarSource{ SecretKeyRef: &kube_core_v1.SecretKeySelector{ LocalObjectReference: kube_core_v1.LocalObjectReference{Name: "test-secret"}, Key: "access_key", }, }, }, }, nsp.envVars) } func TestSecretWithKeyMapping(t *testing.T) { nsp := newNativeSecretsProcessor(&config{ NativeSecretsAllowFromStep: true, }, []SecretRef{ { Name: "test-secret", Key: "aws-secret", Target: SecretTarget{ Env: "AWS_SECRET_ACCESS_KEY", }, }, }) err := nsp.process() assert.NoError(t, err) assert.Empty(t, nsp.envFromSources) assert.Empty(t, nsp.volumes) assert.Empty(t, nsp.mounts) assert.Equal(t, []kube_core_v1.EnvVar{ { Name: "AWS_SECRET_ACCESS_KEY", ValueFrom: &kube_core_v1.EnvVarSource{ SecretKeyRef: &kube_core_v1.SecretKeySelector{ LocalObjectReference: kube_core_v1.LocalObjectReference{Name: "test-secret"}, Key: "aws-secret", }, }, }, }, nsp.envVars) } func TestFileSecret(t *testing.T) { nsp := newNativeSecretsProcessor(&config{ NativeSecretsAllowFromStep: true, }, []SecretRef{ { Name: "reg-cred", Key: ".dockerconfigjson", Target: SecretTarget{ File: "~/.docker/config.json", }, }, }) err := nsp.process() assert.NoError(t, err) assert.Empty(t, nsp.envFromSources) assert.Empty(t, nsp.envVars) assert.Equal(t, []kube_core_v1.Volume{ { Name: "reg-cred", VolumeSource: kube_core_v1.VolumeSource{ Secret: &kube_core_v1.SecretVolumeSource{ SecretName: "reg-cred", }, }, }, }, nsp.volumes) assert.Equal(t, []kube_core_v1.VolumeMount{ { Name: "reg-cred", ReadOnly: true, MountPath: "~/.docker/config.json", SubPath: ".dockerconfigjson", }, }, nsp.mounts) } func TestNoAuthNoSecret(t *testing.T) { assert.False(t, needsRegistrySecret(&types.Step{})) } func TestNoPasswordNoSecret(t *testing.T) { assert.False(t, needsRegistrySecret(&types.Step{ AuthConfig: types.Auth{Username: "foo"}, })) } func TestNoUsernameNoSecret(t *testing.T) { assert.False(t, needsRegistrySecret(&types.Step{ AuthConfig: types.Auth{Password: "foo"}, })) } func TestUsernameAndPasswordNeedsSecret(t *testing.T) { assert.True(t, needsRegistrySecret(&types.Step{ AuthConfig: types.Auth{Username: "foo", Password: "bar"}, })) } func TestRegistrySecret(t *testing.T) { const expected = `{ "metadata": { "name": "wp-01he8bebctabr3kgk0qj36d2me-0", "namespace": "woodpecker", "labels": { "step": "go-test", "woodpecker-ci.org/step": "go-test" } }, "type": "kubernetes.io/dockerconfigjson", "data": { ".dockerconfigjson": "eyJhdXRocyI6eyJkb2NrZXIuaW8iOnsidXNlcm5hbWUiOiJmb28iLCJwYXNzd29yZCI6ImJhciJ9fX0=" } }` secret, err := mkRegistrySecret(&types.Step{ UUID: "01he8bebctabr3kgk0qj36d2me-0", Name: "go-test", Image: "meltwater/drone-cache", AuthConfig: types.Auth{ Username: "foo", Password: "bar", }, }, &config{ Namespace: "woodpecker", }) assert.NoError(t, err) secretJSON, err := json.Marshal(secret) assert.NoError(t, err) ja := jsonassert.New(t) ja.Assertf(string(secretJSON), expected) } ================================================ FILE: pipeline/backend/kubernetes/service.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "github.com/rs/zerolog/log" kube_core_v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) const ( ServiceLabel = "service" HeadlessServicePrefix = "wp-hsvc-" ServicePrefix = "wp-svc-" ) func mkHeadlessService(namespace, taskUUID string) (*kube_core_v1.Service, error) { selector := map[string]string{ TaskUUIDLabel: taskUUID, } name, err := subdomain(taskUUID) if err != nil { return nil, err } log.Trace().Str("name", name).Interface("selector", selector).Msg("creating headless service") return &kube_core_v1.Service{ ObjectMeta: kube_meta_v1.ObjectMeta{ Name: name, Namespace: namespace, }, Spec: kube_core_v1.ServiceSpec{ Type: kube_core_v1.ServiceTypeClusterIP, ClusterIP: "None", Selector: selector, }, }, nil } func serviceName(step *types.Step) (string, error) { return dnsName(ServicePrefix + step.UUID + "-" + step.Name) } func isService(step *types.Step) bool { return step.Type == types.StepTypeService } func subdomain(taskUUID string) (string, error) { return dnsName(HeadlessServicePrefix + taskUUID) } func startHeadlessService(ctx context.Context, engine *kube, namespace, taskUUID string) (*kube_core_v1.Service, error) { svc, err := mkHeadlessService(namespace, taskUUID) if err != nil { return nil, err } log.Trace().Str("name", svc.Name).Interface("selector", svc.Spec.Selector).Msg("creating headless service") return engine.client.CoreV1().Services(namespace).Create(ctx, svc, kube_meta_v1.CreateOptions{}) } func (e *kube) stopHeadlessService(ctx context.Context, engine *kube, namespace, taskUUID string) error { name, err := subdomain(taskUUID) if err != nil { return err } log.Trace().Str("name", name).Msg("deleting headless service") err = engine.client.CoreV1().Services(namespace).Delete(ctx, name, e.config.newDefaultDeleteOptions()) if errors.IsNotFound(err) { // Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps. log.Trace().Err(err).Msgf("unable to delete headless service %s", name) return nil } return err } ================================================ FILE: pipeline/backend/kubernetes/service_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" kube_core_v1 "k8s.io/api/core/v1" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestServiceName(t *testing.T) { name, err := serviceName(&types.Step{Name: "database", UUID: "01he8bebctabr3kgk0qj36d2me"}) assert.NoError(t, err) assert.Equal(t, "wp-svc-01he8bebctabr3kgk0qj36d2me-database", name) name, err = serviceName(&types.Step{Name: "wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local", UUID: "01he8bebctabr3kgk0qj36d2me"}) assert.NoError(t, err) assert.Equal(t, "wp-svc-01he8bebctabr3kgk0qj36d2me-wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local", name) name, err = serviceName(&types.Step{Name: "awesome_service", UUID: "01he8bebctabr3kgk0qj36d2me"}) assert.NoError(t, err) assert.Equal(t, "wp-svc-01he8bebctabr3kgk0qj36d2me-awesome-service", name) } func TestHeadlessService(t *testing.T) { expected := ` { "metadata": { "name": "wp-hsvc-11301", "namespace": "foo" }, "spec": { "selector": { "woodpecker-ci.org/task-uuid": "11301" }, "clusterIP": "None", "type": "ClusterIP" }, "status": { "loadBalancer": {} } }` s, err := mkHeadlessService("foo", "11301") assert.NoError(t, err, "expected no error when creating headless service") j, err := json.Marshal(s) assert.NoError(t, err, "expected no error when marshaling headless service to JSON") assert.JSONEq(t, expected, string(j), "expected headless service JSON to match") } func TestInvalidHeadlessService(t *testing.T) { _, err := mkHeadlessService("foo", "invalid_task_uuid!") assert.Error(t, err, "expected error due to invalid task UUID") } func TestStartHeadlessService(t *testing.T) { t.Run("successfully creates headless service", func(t *testing.T) { engine := &kube{ client: fake.NewClientset(), config: &config{Namespace: "test-namespace"}, } svc, err := startHeadlessService(t.Context(), engine, "foo", "11301") assert.NoError(t, err, "expected no error when starting headless service") assert.NotNil(t, svc, "expected headless service to be created") assert.Equal(t, "wp-hsvc-11301", svc.Name, "expected headless service name to match") assert.Equal(t, "foo", svc.Namespace, "expected headless service namespace to match") assert.Equal(t, kube_core_v1.ServiceTypeClusterIP, svc.Spec.Type, "expected headless service type to be ClusterIP") assert.Equal(t, "None", svc.Spec.ClusterIP, "expected headless service ClusterIP to be 'None'") assert.Equal(t, map[string]string{TaskUUIDLabel: "11301"}, svc.Spec.Selector) createdSvc, err := engine.client.CoreV1().Services("foo").Get(t.Context(), "wp-hsvc-11301", kube_meta_v1.GetOptions{}) assert.NoError(t, err, "expected no error when getting the created service") assert.Equal(t, svc.Name, createdSvc.Name, "expected created service name to match") }) t.Run("error on invalid task UUID resulting in invalid domain-name", func(t *testing.T) { engine := &kube{ client: fake.NewClientset(), config: &config{Namespace: "test-namespace"}, } _, err := startHeadlessService(t.Context(), engine, "test-namespace", "invalid_task_uuid!") assert.Error(t, err, "expected error due to invalid task UUID") }) } func TestStopHeadlessService(t *testing.T) { t.Run("successfully deletes headless service", func(t *testing.T) { engine := &kube{ client: fake.NewClientset(), config: &config{Namespace: "test-namespace"}, } // arrage _, err := startHeadlessService(t.Context(), engine, "foo", "11301") assert.NoError(t, err, "expected no error when starting headless service") _, err = engine.client.CoreV1().Services("foo").Get(t.Context(), "wp-hsvc-11301", kube_meta_v1.GetOptions{}) assert.NoError(t, err, "expected no error when getting the created service") // act err = engine.stopHeadlessService(t.Context(), engine, "foo", "11301") assert.NoError(t, err, "expected no error when deleting headless service") // assert _, err = engine.client.CoreV1().Services("foo").Get(t.Context(), "wp-hsvc-11301", kube_meta_v1.GetOptions{}) assert.Error(t, err, "expected error when getting a deleted service") assert.True(t, err != nil, "expected error to be non-nil") }) t.Run("handles non-existent service gracefully", func(t *testing.T) { engine := &kube{ client: fake.NewClientset(), config: &config{Namespace: "test-namespace"}, } err := engine.stopHeadlessService(t.Context(), engine, "foo", "nonexistent") assert.NoError(t, err, "expected no error when deleting a non-existent service") }) t.Run("error on invalid task UUID resulting in invalid domain-name", func(t *testing.T) { engine := &kube{ client: fake.NewClientset(), config: &config{Namespace: "test-namespace"}, } err := engine.stopHeadlessService(t.Context(), engine, "test-namespace", "invalid_task_uuid!") assert.Error(t, err, "expected error due to invalid task UUID") }) } ================================================ FILE: pipeline/backend/kubernetes/utils.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "errors" "os" "regexp" "strings" kube_core_v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" kube_client_cmd "k8s.io/client-go/tools/clientcmd" ) const maxDNSLabelLen = 63 var ( dnsPattern = regexp.MustCompile(`^[a-z0-9]` + // must start with `([-a-z0-9]*[a-z0-9])?` + // inside can als contain - `(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`, // allow the same pattern as before with dots in between but only one dot ) dnsLabelPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) dnsDisallowedCharacters = regexp.MustCompile(`[^-^.a-z0-9]+`) ErrDNSPatternInvalid = errors.New("name is not a valid kubernetes DNS name") ) func getHostnameOrEmpty(name string) string { clean, _ := toDNSName(name) if clean == "" { clean = strings.ToLower(name) } clean = strings.ReplaceAll(clean, ".", "-") if len(clean) > maxDNSLabelLen { clean = clean[:maxDNSLabelLen] } clean = strings.Trim(clean, "-") if dnsLabelPattern.MatchString(clean) { return clean } return "" } func dnsName(i string) (string, error) { res := strings.ToLower(strings.ReplaceAll(i, "_", "-")) if found := dnsPattern.FindStringIndex(res); found == nil { return "", ErrDNSPatternInvalid } return res, nil } func toDNSName(in string) (string, error) { lower := strings.ToLower(in) withoutUnderscores := strings.ReplaceAll(lower, "_", "-") withoutSpaces := strings.ReplaceAll(withoutUnderscores, " ", "-") almostDNS := dnsDisallowedCharacters.ReplaceAllString(withoutSpaces, "") return dnsName(almostDNS) } func isImagePullBackOffState(pod *kube_core_v1.Pod) bool { for _, containerState := range pod.Status.ContainerStatuses { if containerState.State.Waiting != nil { if containerState.State.Waiting.Reason == "ImagePullBackOff" { return true } } } return false } func isInvalidImageName(pod *kube_core_v1.Pod) bool { for _, containerState := range pod.Status.ContainerStatuses { if containerState.State.Waiting != nil { if containerState.State.Waiting.Reason == "InvalidImageName" { return true } } } return false } // getClientOutOfCluster returns a k8s client set to the request from outside of cluster. func getClientOutOfCluster() (kubernetes.Interface, error) { kubeConfigPath := os.Getenv("KUBECONFIG") // cspell:words KUBECONFIG if kubeConfigPath == "" { kubeConfigPath = os.Getenv("HOME") + "/.kube/config" } // use the current context in kube config config, err := kube_client_cmd.BuildConfigFromFlags("", kubeConfigPath) if err != nil { return nil, err } return kubernetes.NewForConfig(config) } // getClientInsideOfCluster returns a k8s client set to the request from inside of cluster. func getClientInsideOfCluster() (kubernetes.Interface, error) { config, err := rest.InClusterConfig() if err != nil { return nil, err } return kubernetes.NewForConfig(config) } func newBool(val bool) *bool { ptr := new(bool) *ptr = val return ptr } func newInt64(val int64) *int64 { ptr := new(int64) *ptr = val return ptr } ================================================ FILE: pipeline/backend/kubernetes/utils_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "testing" "github.com/stretchr/testify/assert" ) func TestDNSName(t *testing.T) { name, err := dnsName("wp_01he8bebctabr3kgk0qj36d2me_0_services_0") assert.NoError(t, err) assert.Equal(t, "wp-01he8bebctabr3kgk0qj36d2me-0-services-0", name) name, err = dnsName("a.0-AA") assert.NoError(t, err) assert.Equal(t, "a.0-aa", name) name, err = dnsName("wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local") assert.NoError(t, err) assert.Equal(t, "wp-01he8bebctabr3kgk0qj36d2me-0-services-0.woodpecker-runtime.svc.cluster.local", name) _, err = dnsName(".0-a") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = dnsName("ABC..DEF") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = dnsName("0.-a") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = dnsName("test-") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = dnsName("-test") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = dnsName("0-a.") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = dnsName("abc\\def") assert.ErrorIs(t, err, ErrDNSPatternInvalid) } func TestToDnsName(t *testing.T) { name, err := toDNSName("BUILD_AND_DEPLOY_0") assert.NoError(t, err) assert.Equal(t, "build-and-deploy-0", name) name, err = toDNSName("build and deploy") assert.NoError(t, err) assert.Equal(t, "build-and-deploy", name) name, err = toDNSName("build & deploy") assert.NoError(t, err) assert.Equal(t, "build--deploy", name) _, err = toDNSName("-build-and-deploy") assert.ErrorIs(t, err, ErrDNSPatternInvalid) } func TestGetHostnameOrEmpty(t *testing.T) { tests := []struct { in string want string }{ {"Update repos", "update-repos"}, {"MY_STEP", "my-step"}, {"Build 🚀", ""}, } for _, tt := range tests { got := getHostnameOrEmpty(tt.in) assert.Equal(t, tt.want, got, "input: %q", tt.in) } } ================================================ FILE: pipeline/backend/kubernetes/volume.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "context" "strings" "github.com/rs/zerolog/log" kube_core_v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" kube_meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) func mkPersistentVolumeClaim(config *config, name, namespace string) (*kube_core_v1.PersistentVolumeClaim, error) { _storageClass := &config.StorageClass if config.StorageClass == "" { _storageClass = nil } var accessMode kube_core_v1.PersistentVolumeAccessMode if config.StorageRwx { accessMode = kube_core_v1.ReadWriteMany } else { accessMode = kube_core_v1.ReadWriteOnce } volumeName, err := volumeName(name) if err != nil { return nil, err } pvc := &kube_core_v1.PersistentVolumeClaim{ ObjectMeta: kube_meta_v1.ObjectMeta{ Name: volumeName, Namespace: namespace, }, Spec: kube_core_v1.PersistentVolumeClaimSpec{ AccessModes: []kube_core_v1.PersistentVolumeAccessMode{accessMode}, StorageClassName: _storageClass, Resources: kube_core_v1.VolumeResourceRequirements{ Requests: kube_core_v1.ResourceList{ kube_core_v1.ResourceStorage: resource.MustParse(config.VolumeSize), }, }, }, } return pvc, nil } func volumeName(name string) (string, error) { return dnsName(strings.Split(name, ":")[0]) } func volumeMountPath(name string) string { s := strings.Split(name, ":") if len(s) > 1 { return s[1] } return s[0] } func startVolume(ctx context.Context, engine *kube, name, namespace string) (*kube_core_v1.PersistentVolumeClaim, error) { engineConfig := engine.getConfig() pvc, err := mkPersistentVolumeClaim(engineConfig, name, namespace) if err != nil { return nil, err } log.Trace().Msgf("creating volume: %s", pvc.Name) return engine.client.CoreV1().PersistentVolumeClaims(namespace).Create(ctx, pvc, kube_meta_v1.CreateOptions{}) } func stopVolume(ctx context.Context, engine *kube, name, namespace string, deleteOpts kube_meta_v1.DeleteOptions) error { pvcName, err := volumeName(name) if err != nil { return err } log.Trace().Str("name", pvcName).Msg("deleting volume") err = engine.client.CoreV1().PersistentVolumeClaims(namespace).Delete(ctx, pvcName, deleteOpts) if errors.IsNotFound(err) { // Don't abort on 404 errors from k8s, they most likely mean that the pod hasn't been created yet, usually because pipeline was canceled before running all steps. log.Trace().Err(err).Msgf("unable to delete service %s", pvcName) return nil } return err } ================================================ FILE: pipeline/backend/kubernetes/volume_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package kubernetes import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" ) func TestPvcName(t *testing.T) { name, err := volumeName("woodpecker_cache:/woodpecker/src/cache") assert.NoError(t, err) assert.Equal(t, "woodpecker-cache", name) _, err = volumeName("woodpecker\\cache") assert.ErrorIs(t, err, ErrDNSPatternInvalid) _, err = volumeName("-woodpecker.cache:/woodpecker/src/cache") assert.ErrorIs(t, err, ErrDNSPatternInvalid) } func TestPvcMount(t *testing.T) { mount := volumeMountPath("woodpecker-cache:/woodpecker/src/cache") assert.Equal(t, "/woodpecker/src/cache", mount) mount = volumeMountPath("/woodpecker/src/cache") assert.Equal(t, "/woodpecker/src/cache", mount) } func TestPersistentVolumeClaim(t *testing.T) { namespace := "someNamespace" expectedRwx := ` { "metadata": { "name": "somename", "namespace": "someNamespace" }, "spec": { "accessModes": [ "ReadWriteMany" ], "resources": { "requests": { "storage": "1Gi" } }, "storageClassName": "local-storage" }, "status": {} }` expectedRwo := ` { "metadata": { "name": "somename", "namespace": "someNamespace" }, "spec": { "accessModes": [ "ReadWriteOnce" ], "resources": { "requests": { "storage": "1Gi" } }, "storageClassName": "local-storage" }, "status": {} }` pvc, err := mkPersistentVolumeClaim(&config{ Namespace: namespace, StorageClass: "local-storage", VolumeSize: "1Gi", StorageRwx: true, }, "somename", namespace) assert.NoError(t, err) j, err := json.Marshal(pvc) assert.NoError(t, err) assert.JSONEq(t, expectedRwx, string(j)) pvc, err = mkPersistentVolumeClaim(&config{ Namespace: namespace, StorageClass: "local-storage", VolumeSize: "1Gi", StorageRwx: false, }, "somename", namespace) assert.NoError(t, err) j, err = json.Marshal(pvc) assert.NoError(t, err) assert.JSONEq(t, expectedRwo, string(j)) _, err = mkPersistentVolumeClaim(&config{ Namespace: namespace, StorageClass: "local-storage", VolumeSize: "1Gi", StorageRwx: false, }, "some0..INVALID3name", namespace) assert.Error(t, err) } ================================================ FILE: pipeline/backend/local/clone.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // checkGitCloneCap check if we have the git binary on hand. func checkGitCloneCap() error { _, err := exec.LookPath("git") return err } // loadClone on backend start determine if there is a global plugin-git binary. func (e *local) loadClone() { binary, err := exec.LookPath("plugin-git") if err != nil || binary == "" { // could not found global git plugin, just ignore it return } e.pluginGitBinary = binary } // setupClone prepare the clone environment before exec. func (e *local) setupClone(ctx context.Context, state *workflowState) error { if e.pluginGitBinary != "" { state.pluginGitBinary = e.pluginGitBinary return nil } log.Info().Msg("no global 'plugin-git' installed, try to download for current workflow") state.pluginGitBinary = filepath.Join(state.homeDir, "plugin-git") if e.os == "windows" { state.pluginGitBinary += ".exe" } return e.downloadLatestGitPluginBinary(ctx, state.pluginGitBinary) } // execClone executes a clone-step locally. func (e *local) execClone(ctx context.Context, step *types.Step, state *workflowState, env []string) error { if err := checkGitCloneCap(); err != nil { return fmt.Errorf("check for git clone capabilities failed: %w", err) } if err := e.setupClone(ctx, state); err != nil { return fmt.Errorf("setup clone step failed: %w", err) } if !strings.Contains(step.Image, "plugin-git") { log.Warn().Msgf("clone step image '%s' does not match default git clone image. We ignore it and use our plugin-git anyway.", step.Image) } rmCmd, err := e.writeNetRC(step, state) if err != nil { return err } // Prepare command var cmd *exec.Cmd if rmCmd != "" { // if we have a netrc injected we have to make sure it's deleted in any case after clone was attempted if e.os == "windows" { pwsh, err := exec.LookPath("powershell.exe") if err != nil { return err } cmd = exec.CommandContext(ctx, pwsh, "-Command", fmt.Sprintf("%s ; $code=$? ; %s ; if (!$code) {[Environment]::Exit(1)}", state.pluginGitBinary, rmCmd)) } else { cmd = exec.CommandContext(ctx, "/bin/sh", "-c", fmt.Sprintf("%s ; export code=$? ; %s ; exit $code", state.pluginGitBinary, rmCmd)) } } else { // if we have NO netrc, we can just exec the clone directly cmd = exec.CommandContext(ctx, state.pluginGitBinary) } cmd.Env = env cmd.Dir = state.workspaceDir reader, err := cmd.StdoutPipe() if err != nil { return err } // Save state state.stepState.Store(step.UUID, &stepState{ cmd: cmd, output: reader, }) // Get output and redirect Stderr to Stdout cmd.Stderr = cmd.Stdout return cmd.Start() } // writeNetRC write a netrc file into the home dir of a given workflow state. func (e *local) writeNetRC(step *types.Step, state *workflowState) (string, error) { if step.Environment["CI_NETRC_MACHINE"] == "" { log.Trace().Msg("no netrc to write") return "", nil } if !e.isolatedHome { log.Trace().Msg("writing .netrc skipped due to disabled isolated home") return "", nil } file := filepath.Join(state.homeDir, ".netrc") rmCmd := fmt.Sprintf("rm \"%s\"", file) if e.os == "windows" { file = filepath.Join(state.homeDir, "_netrc") rmCmd = fmt.Sprintf("del \"%s\"", file) } log.Trace().Msgf("try to write netrc to '%s'", file) return rmCmd, os.WriteFile(file, []byte(genNetRC(step.Environment)), 0o600) } // downloadLatestGitPluginBinary download the latest plugin-git binary based on runtime OS and Arch // and saves it to dest. func (e *local) downloadLatestGitPluginBinary(ctx context.Context, dest string) error { type asset struct { Name string BrowserDownloadURL string `json:"browser_download_url"` } type release struct { Assets []asset } // get latest release req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/woodpecker-ci/plugin-git/releases/latest", nil) req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("could not get latest release: %w", err) } raw, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() var rel release if err := json.Unmarshal(raw, &rel); err != nil { return fmt.Errorf("could not unmarshal github response: %w", err) } for _, at := range rel.Assets { if strings.Contains(at.Name, e.os) && strings.Contains(at.Name, e.arch) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, at.BrowserDownloadURL, nil) if err != nil { return err } assetResp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("could not download plugin-git: %w", err) } defer assetResp.Body.Close() file, err := os.Create(dest) if err != nil { return fmt.Errorf("could not create plugin-git: %w", err) } defer file.Close() if _, err := io.Copy(file, assetResp.Body); err != nil { return fmt.Errorf("could not download plugin-git: %w", err) } if err := os.Chmod(dest, 0o755); err != nil { return err } // download successful log.Trace().Msgf("download of 'plugin-git' to '%s' successful", dest) return nil } } return fmt.Errorf("could not download plugin-git, binary for this os/arch not found") } ================================================ FILE: pipeline/backend/local/command.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // cSpell:ignore ERRORLEVEL package local import ( "context" "fmt" "io" "os" "os/exec" "strings" "al.essio.dev/pkg/shellescape" "golang.org/x/text/encoding/unicode" "golang.org/x/text/transform" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // execCommands use step.Image as shell and run the commands in it. func (e *local) execCommands(ctx context.Context, step *types.Step, state *workflowState, env []string) error { if err := checkShellExistence(step.Image); err != nil { return err } // Prepare commands // TODO: support `entrypoint` from pipeline config args, err := e.genCmdByShell(step.Image, step.Commands, state.baseDir) if err != nil { return fmt.Errorf("could not convert commands into args: %w", err) } // Use "image name" as run command (indicate shell) cmd := exec.CommandContext(ctx, step.Image, args...) cmd.Env = env cmd.Dir = state.workspaceDir reader, err := cmd.StdoutPipe() if err != nil { return err } if e.os == "windows" { // we get non utf8 output from windows so just sanitize it // TODO: remove hack reader = io.NopCloser(transform.NewReader(reader, unicode.UTF8.NewDecoder().Transformer)) } // Get output and redirect Stderr to Stdout cmd.Stderr = cmd.Stdout // Save state state.stepState.Store(step.UUID, &stepState{ cmd: cmd, output: reader, }) return cmd.Start() } func checkShellExistence(shell string) error { _, err := exec.LookPath(shell) return err } func (e *local) genCmdByShell(shell string, cmdList []string, baseDir string) (args []string, err error) { if len(cmdList) == 0 { return nil, ErrNoCmdSet } script := "" for _, cmd := range cmdList { script += fmt.Sprintf("echo %s\n%s\n", strings.TrimSpace(shellescape.Quote("+ "+cmd)), cmd) } script = strings.TrimSpace(script) shell = strings.TrimSuffix(strings.ToLower(shell), ".exe") switch shell { default: // assume posix shell if err := probeShellIsPosix(shell); err != nil { return nil, err } fallthrough // normal posix shells case "sh", "bash", "zsh": return []string{"-e", "-c", script}, nil case "": return nil, ErrNoShellSet case "cmd": script := "@SET PROMPT=$\n" for _, cmd := range cmdList { quotedCmd := strings.TrimSpace(shellescape.Quote(cmd)) // As cmd echo does not allow strings with newlines we need to replace them ... quotedCmd = strings.ReplaceAll(quotedCmd, "\n", "\\n") // Also the shellescape.Quote fail with any | or & char and wrapping them in quotes again can be bypassed // by just leaving an string halve quoted we just replace them with symbolic representations quotedCmd = strings.ReplaceAll(quotedCmd, "&", "\\AND") quotedCmd = strings.ReplaceAll(quotedCmd, "|", "\\OR") script += fmt.Sprintf("@echo + %s\n", quotedCmd) script += fmt.Sprintf("@%s\n", cmd) script += "@IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL%\n" } cmd, err := os.CreateTemp(baseDir, "*.cmd") if err != nil { return nil, err } defer cmd.Close() if _, err := cmd.WriteString(script); err != nil { return nil, err } return []string{"/c", cmd.Name()}, nil case "fish": script := "" for _, cmd := range cmdList { script += fmt.Sprintf("echo %s\n%s || exit $status\n", strings.TrimSpace(shellescape.Quote("+ "+cmd)), cmd) } return []string{"-c", script}, nil case "nu": return []string{"--commands", script}, nil case "powershell", "pwsh": // cspell:disable-next-line return []string{"-noprofile", "-noninteractive", "-c", "$ErrorActionPreference = \"Stop\"; " + script}, nil } } // before we generate a generic posix shell we test. func probeShellIsPosix(shell string) error { script := `x=1 && [ "$x" = "1" ] && command -v test >/dev/null && printf ok` cmd := exec.Command(shell, "-c", script) output, err := cmd.CombinedOutput() if err != nil { return &ErrNoPosixShell{Shell: shell, Err: err} } if strings.TrimSpace(string(output)) != "ok" { return &ErrNoPosixShell{Shell: shell, Err: fmt.Errorf("unexpected output returned: %q", string(output))} } return nil } ================================================ FILE: pipeline/backend/local/command_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "os" "runtime" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGenCmdByShell(t *testing.T) { tmpDir := t.TempDir() e := local{tempDir: tmpDir} t.Run("error cases", func(t *testing.T) { args, err := e.genCmdByShell("", []string{"echo hi"}, t.TempDir()) assert.Nil(t, args) assert.ErrorIs(t, err, ErrNoShellSet) args, err = e.genCmdByShell("sh", []string{}, t.TempDir()) assert.Nil(t, args) assert.ErrorIs(t, err, ErrNoCmdSet) }) t.Run("windows shells", func(t *testing.T) { t.Run("cmd", func(t *testing.T) { args, err := e.genCmdByShell("cmd.exe", []string{"echo hi", "call build.bat"}, t.TempDir()) require.NoError(t, err) require.Len(t, args, 2) assert.Equal(t, "/c", args[0]) assert.True(t, strings.HasSuffix(args[1], ".cmd")) // Verify the temp file was created and contains expected content content, err := os.ReadFile(args[1]) require.NoError(t, err) assert.EqualValues(t, `@SET PROMPT=$ @echo + 'echo hi' @echo hi @IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL% @echo + 'call build.bat' @call build.bat @IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL% `, string(content)) }) t.Run("powershell", func(t *testing.T) { args, err := e.genCmdByShell("powershell", []string{"Write-Host 'test'", "echo test"}, t.TempDir()) require.NoError(t, err) require.Len(t, args, 4) assert.EqualValues(t, []string{"-noprofile", "-noninteractive", "-c"}, []string{args[0], args[1], args[2]}) assert.EqualValues(t, `$ErrorActionPreference = "Stop"; echo '+ Write-Host '"'"'test'"'"'' Write-Host 'test' echo '+ echo test' echo test`, args[3]) args, err = e.genCmdByShell("pwsh", []string{"Get-Process"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 4) assert.Equal(t, "-noprofile", args[0]) }) }) t.Run("unix shells", func(t *testing.T) { args, err := e.genCmdByShell("sh", []string{"echo hello", "pwd"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 3) assert.Equal(t, "-e", args[0]) assert.Equal(t, "-c", args[1]) assert.Contains(t, args[2], "echo hello") assert.Contains(t, args[2], "pwd") args, err = e.genCmdByShell("bash", []string{"ls -la"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 3) assert.Equal(t, "-e", args[0]) assert.Equal(t, "-c", args[1]) args, err = e.genCmdByShell("zsh", []string{"echo test"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 3) assert.Equal(t, "-e", args[0]) }) t.Run("fish shell", func(t *testing.T) { args, err := e.genCmdByShell("fish", []string{"echo test", "ls"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 2) assert.Equal(t, "-c", args[0]) assert.Contains(t, args[1], "echo test") assert.Contains(t, args[1], "|| exit $status") }) t.Run("nu shell", func(t *testing.T) { args, err := e.genCmdByShell("nu", []string{"echo test"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 2) assert.Equal(t, "--commands", args[0]) assert.Contains(t, args[1], "echo test") }) t.Run("command escaping", func(t *testing.T) { args, err := e.genCmdByShell("cmd", []string{"echo 'test with | pipe'", "echo 'test & ampersand'\n\necho new line"}, t.TempDir()) require.NoError(t, err) content, err := os.ReadFile(args[1]) require.NoError(t, err) assert.EqualValues(t, `@SET PROMPT=$ @echo + 'echo '"'"'test with \OR pipe'"'"'' @echo 'test with | pipe' @IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL% @echo + 'echo '"'"'test \AND ampersand'"'"'\n\necho new line' @echo 'test & ampersand' echo new line @IF NOT %ERRORLEVEL% == 0 exit %ERRORLEVEL% `, string(content)) }) t.Run("shell with .exe suffix", func(t *testing.T) { args, err := e.genCmdByShell("bash.exe", []string{"echo test"}, t.TempDir()) require.NoError(t, err) assert.Len(t, args, 3) assert.Equal(t, "-e", args[0]) }) } func TestProbeShellIsPosix(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("skipping posix shell tests on non-linux system") } t.Run("valid posix shells", func(t *testing.T) { err := probeShellIsPosix("sh") assert.NoError(t, err) }) t.Run("invalid shell", func(t *testing.T) { err := probeShellIsPosix("nonexistentshell12345") if assert.ErrorIs(t, err, &ErrNoPosixShell{}) { assert.Equal(t, `Shell "nonexistentshell12345" was assumed to be a Posix shell, but test failed: exec: "nonexistentshell12345": executable file not found in $PATH (if you want support for it, please open an issue)`, err.Error()) } }) t.Run("non-posix shell", func(t *testing.T) { // nologin won't understand posix syntax err := probeShellIsPosix("true") if assert.ErrorIs(t, err, &ErrNoPosixShell{}) { assert.Equal(t, `Shell "true" was assumed to be a Posix shell, but test failed: unexpected output returned: "" (if you want support for it, please open an issue)`, err.Error()) } }) } ================================================ FILE: pipeline/backend/local/const.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "fmt" ) // notAllowedEnvVarOverwrites are all env vars that cannot be overwritten by step config. var notAllowedEnvVarOverwrites = []string{ "CI_NETRC_MACHINE", "CI_NETRC_USERNAME", "CI_NETRC_PASSWORD", "CI_SCRIPT", "HOME", "SHELL", "CI_WORKSPACE", } const netrcFile = ` machine %s login %s password %s ` func genNetRC(env map[string]string) string { return fmt.Sprintf( netrcFile, env["CI_NETRC_MACHINE"], env["CI_NETRC_USERNAME"], env["CI_NETRC_PASSWORD"], ) } ================================================ FILE: pipeline/backend/local/const_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "testing" "github.com/stretchr/testify/assert" ) func TestGenNetRC(t *testing.T) { assert.Equal(t, ` machine machine login user password pass `, genNetRC(map[string]string{ "CI_NETRC_MACHINE": "machine", "CI_NETRC_USERNAME": "user", "CI_NETRC_PASSWORD": "pass", })) } ================================================ FILE: pipeline/backend/local/errors.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // cSpell:ignore ERRORLEVEL package local import ( "errors" "fmt" ) var ( ErrUnsupportedStepType = errors.New("unsupported step type") ErrStepReaderNotFound = errors.New("could not found pipe reader for step") ErrWorkflowStateNotFound = errors.New("workflow state not found") ErrStepStateNotFound = errors.New("step state not found") ErrNoShellSet = errors.New("no shell was set") ErrNoCmdSet = errors.New("no commands where set") ) // ErrNoPosixShell indicates that a shell was assumed to be POSIX-compatible but failed the test. type ErrNoPosixShell struct { Shell string Err error } func (e *ErrNoPosixShell) Error() string { return fmt.Sprintf("Shell %q was assumed to be a Posix shell, but test failed: %v\n(if you want support for it, please open an issue)", e.Shell, e.Err) } // Unwrap returns the underlying error for errors.Is and errors.As support. func (e *ErrNoPosixShell) Unwrap() error { return e.Err } // Is enables errors.Is comparison. func (e *ErrNoPosixShell) Is(target error) bool { _, ok := target.(*ErrNoPosixShell) return ok } ================================================ FILE: pipeline/backend/local/flags.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "os" "github.com/urfave/cli/v3" ) var Flags = []cli.Flag{ &cli.StringFlag{ Name: "backend-local-temp-dir", Sources: cli.EnvVars("WOODPECKER_BACKEND_LOCAL_TEMP_DIR"), Usage: "set a different temp dir to clone workflows into", DefaultText: "system temporary directory", Value: os.TempDir(), }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_BACKEND_LOCAL_ISOLATED_HOME"), Name: "backend-local-isolated-home", Usage: "set HOME, USERPROFILE and other variables to an isolated directory, if false we ignore netrc", Value: true, }, } ================================================ FILE: pipeline/backend/local/local.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "slices" "sync" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) type workflowState struct { stepState sync.Map // map of *stepState baseDir string homeDir string workspaceDir string pluginGitBinary string } type stepState struct { cmd *exec.Cmd output io.ReadCloser } type local struct { tempDir string isolatedHome bool workflows sync.Map pluginGitBinary string os, arch string } var CLIWorkaroundExecAtDir string // To handle edge case for running local backend via cli exec // New returns a new local Backend. func New() types.Backend { return &local{ os: runtime.GOOS, arch: runtime.GOARCH, } } func (e *local) Name() string { return "local" } func (e *local) IsAvailable(ctx context.Context) bool { if c, ok := ctx.Value(types.CliCommand).(*cli.Command); ok { if c.String("backend-engine") == e.Name() { return true } } _, inContainer := os.LookupEnv("WOODPECKER_IN_CONTAINER") return !inContainer } func (e *local) Flags() []cli.Flag { return Flags } func (e *local) Load(ctx context.Context) (*types.BackendInfo, error) { c, ok := ctx.Value(types.CliCommand).(*cli.Command) if ok { e.tempDir = c.String("backend-local-temp-dir") e.isolatedHome = c.Bool("backend-local-isolated-home") } e.loadClone() return &types.BackendInfo{ Platform: e.os + "/" + e.arch, }, nil } func (e *local) SetupWorkflow(_ context.Context, _ *types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msg("create workflow environment") baseDir, err := os.MkdirTemp(e.tempDir, "woodpecker-local-*") if err != nil { return err } state := &workflowState{ baseDir: baseDir, homeDir: filepath.Join(baseDir, "home"), } e.workflows.Store(taskUUID, state) if err := os.Mkdir(state.homeDir, 0o700); err != nil { return err } // normal workspace setup case if CLIWorkaroundExecAtDir == "" { state.workspaceDir = filepath.Join(baseDir, "workspace") if err := os.Mkdir(state.workspaceDir, 0o700); err != nil { return err } } else // setup workspace via internal flag signaled from cli exec to a specific dir { state.workspaceDir = CLIWorkaroundExecAtDir if stat, err := os.Stat(CLIWorkaroundExecAtDir); os.IsNotExist(err) { log.Debug().Msgf("create workspace directory '%s' set by internal flag", CLIWorkaroundExecAtDir) if err := os.Mkdir(state.workspaceDir, 0o700); err != nil { return err } } else if !stat.IsDir() { //nolint:forbidigo log.Fatal().Msg("This should never happen! internalExecDir was set to an non directory path!") } } e.workflows.Store(taskUUID, state) return nil } func (e *local) StartStep(ctx context.Context, step *types.Step, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msgf("start step %s", step.Name) state, err := e.getWorkflowState(taskUUID) if err != nil { return err } // Get environment variables env := os.Environ() for a, b := range step.Environment { // append allowed env vars to command env if !slices.Contains(notAllowedEnvVarOverwrites, a) { env = append(env, a+"="+b) } } if e.isolatedHome { env = append(env, "HOME="+state.homeDir) env = append(env, "USERPROFILE="+state.homeDir) } env = append(env, "CI_WORKSPACE="+state.workspaceDir) switch step.Type { case types.StepTypeClone: return e.execClone(ctx, step, state, env) case types.StepTypeCommands: return e.execCommands(ctx, step, state, env) case types.StepTypePlugin: return e.execPlugin(ctx, step, state, env) default: return ErrUnsupportedStepType } } func (e *local) WaitStep(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error) { log.Trace().Str("taskUUID", taskUUID).Msgf("wait for step %s", step.Name) stepState := &types.State{ Exited: true, } if err := ctx.Err(); err != nil { stepState.Error = err return stepState, nil } state, err := e.getStepState(taskUUID, step.UUID) if err != nil { return nil, err } if state.cmd == nil { return nil, errors.New("exec: step command not set up") } // normally we use cmd.Wait() to wait for *exec.Cmd, but cmd.StdoutPipe() tells us not // as Wait() would close the io pipe even if not all logs where read and send back // so we have to do use the underlying functions if state.cmd.Process == nil { return nil, errors.New("exec: not started") } if state.cmd.ProcessState == nil { cmdState, err := state.cmd.Process.Wait() if err != nil { return nil, err } if cmdState == nil { return nil, errors.New("exec: cmd state after Wait() can not be nil but is") } stepState.ExitCode = cmdState.ExitCode() // can be nil if step got canceled if state.cmd != nil { state.cmd.ProcessState = cmdState } } else { stepState.ExitCode = state.cmd.ProcessState.ExitCode() } return stepState, err } func (e *local) TailStep(_ context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error) { state, err := e.getStepState(taskUUID, step.UUID) if err != nil { return nil, err } else if state.output == nil { return nil, ErrStepReaderNotFound } return state.output, nil } func (e *local) DestroyStep(_ context.Context, step *types.Step, taskUUID string) error { state, err := e.getStepState(taskUUID, step.UUID) if err != nil { if errors.Is(err, ErrStepStateNotFound) { return nil } return err } // As WaitStep can not use cmd.Wait() witch ensures the process already finished and // the io pipe is closed on process end, we make sure it is done. if state.output != nil { _ = state.output.Close() state.output = nil } if state.cmd != nil { _ = state.cmd.Cancel() state.cmd = nil } workflowState, err := e.getWorkflowState(taskUUID) if err != nil { return err } workflowState.stepState.Delete(step.UUID) return nil } func (e *local) DestroyWorkflow(_ context.Context, _ *types.Config, taskUUID string) error { log.Trace().Str("taskUUID", taskUUID).Msg("delete workflow environment") state, err := e.getWorkflowState(taskUUID) if err != nil { return err } // clean up steps not cleaned up because of context cancel or detached function state.stepState.Range(func(_, value any) bool { if state, ok := value.(*stepState); ok && state != nil { if state.output != nil { _ = state.output.Close() state.output = nil } if state.cmd != nil { _ = state.cmd.Cancel() state.cmd = nil } } return true }) err = os.RemoveAll(state.baseDir) if err != nil { return err } // hint for the gc to clean stuff state.stepState.Clear() e.workflows.Delete(taskUUID) return err } func (e *local) getWorkflowState(taskUUID string) (*workflowState, error) { state, ok := e.workflows.Load(taskUUID) if !ok { return nil, ErrWorkflowStateNotFound } s, ok := state.(*workflowState) if !ok || s == nil { return nil, fmt.Errorf("could not parse state: %v", state) } return s, nil } func (e *local) getStepState(taskUUID, stepUUID string) (*stepState, error) { wState, err := e.getWorkflowState(taskUUID) if err != nil { return nil, err } state, ok := wState.stepState.Load(stepUUID) if !ok { return nil, ErrStepStateNotFound } s, ok := state.(*stepState) if !ok || s == nil { return nil, fmt.Errorf("could not parse state: %v", state) } return s, nil } ================================================ FILE: pipeline/backend/local/local_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build linux package local import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "slices" "strings" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestIsAvailable(t *testing.T) { t.Run("not available in container", func(t *testing.T) { backend := New() t.Setenv("WOODPECKER_IN_CONTAINER", "true") available := backend.IsAvailable(context.Background()) assert.False(t, available) }) t.Run("available without container env and no cli context", func(t *testing.T) { backend := New() os.Unsetenv("WOODPECKER_IN_CONTAINER") available := backend.IsAvailable(context.Background()) assert.True(t, available) }) } func TestLoad(t *testing.T) { backend, _ := New().(*local) t.Run("load without cli context", func(t *testing.T) { ctx := context.Background() info, err := backend.Load(ctx) require.NoError(t, err) assert.NotNil(t, info) assert.Equal(t, runtime.GOOS+"/"+runtime.GOARCH, info.Platform) }) t.Run("load with cli context and temp dir", func(t *testing.T) { tmpDir := t.TempDir() cmd := &cli.Command{} cmd.Flags = []cli.Flag{ &cli.StringFlag{ Name: "backend-local-temp-dir", Value: tmpDir, }, } ctx := context.WithValue(context.Background(), types.CliCommand, cmd) info, err := backend.Load(ctx) require.NoError(t, err) assert.NotNil(t, info) assert.Equal(t, tmpDir, backend.tempDir) assert.Equal(t, runtime.GOOS+"/"+runtime.GOARCH, info.Platform) }) } func TestSetupWorkflow(t *testing.T) { backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := context.Background() taskUUID := "test-task-uuid-123" config := &types.Config{} err := backend.SetupWorkflow(ctx, config, taskUUID) require.NoError(t, err) // Verify state was saved state, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) assert.NotNil(t, state) assert.NotEmpty(t, state.baseDir) assert.NotEmpty(t, state.workspaceDir) assert.NotEmpty(t, state.homeDir) // Verify directories were created assert.DirExists(t, state.baseDir) assert.DirExists(t, state.workspaceDir) assert.DirExists(t, state.homeDir) // Verify directory structure assert.Equal(t, filepath.Join(state.baseDir, "workspace"), state.workspaceDir) assert.Equal(t, filepath.Join(state.baseDir, "home"), state.homeDir) // Cleanup assert.NoError(t, os.RemoveAll(state.baseDir)) } func TestDestroyWorkflow(t *testing.T) { backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := context.Background() taskUUID := "test-destroy-task" config := &types.Config{} // Setup workflow first err := backend.SetupWorkflow(ctx, config, taskUUID) require.NoError(t, err) state, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) baseDir := state.baseDir // Verify directory exists assert.DirExists(t, baseDir) // Destroy workflow err = backend.DestroyWorkflow(ctx, config, taskUUID) require.NoError(t, err) // Verify directory was removed assert.NoDirExists(t, baseDir) // Verify state was deleted _, err = backend.getWorkflowState(taskUUID) assert.ErrorIs(t, err, ErrWorkflowStateNotFound) } func prepairEnv(t *testing.T) { prevEnv := os.Environ() os.Clearenv() t.Cleanup(func() { for i := range prevEnv { env := strings.SplitN(prevEnv[i], "=", 2) //nolint:usetesting // reason: the suggested t.Setenv will be undone on t.Run() end witch we explizite dont want here _ = os.Setenv(env[0], env[1]) } }) } func TestRunStep(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("skipping on non linux due to shell availability and symlink capability") } // we lookup shell tools we use first and create the PATH var based on that shBinary, err := exec.LookPath("sh") require.NoError(t, err) path := []string{filepath.Dir(shBinary)} echoBinary, err := exec.LookPath("echo") require.NoError(t, err) if echoPath := filepath.Dir(echoBinary); !slices.Contains(path, echoPath) { path = append(path, echoPath) } // we make a symlinc to have a posix but non default shell altShellDir := t.TempDir() altShellPath := filepath.Join(altShellDir, "altsh") require.NoError(t, os.Symlink(shBinary, altShellPath)) path = append(path, altShellDir) prepairEnv(t) //nolint:usetesting // reason: we use prepairEnv() os.Setenv("PATH", strings.Join(path, ":")) backend, _ := New().(*local) backend.tempDir = t.TempDir() backend.isolatedHome = true ctx := t.Context() taskUUID := "test-run-tasks" // Setup workflow require.NoError(t, backend.SetupWorkflow(ctx, &types.Config{}, taskUUID)) t.Run("type commands", func(t *testing.T) { step := &types.Step{ UUID: "step-1", Name: "test-step", Type: types.StepTypeCommands, Image: "sh", Commands: []string{"echo hello", "env"}, Environment: map[string]string{ "TEST_VAR": "test_value", }, } t.Run("start successful", func(t *testing.T) { err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) // Verify command was started state, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) stepStateWraped, contains := state.stepState.Load(step.UUID) assert.True(t, contains) stepState, _ := stepStateWraped.(*stepState) assert.NotNil(t, stepState.cmd) var outputData []byte outputDataMutex := sync.Mutex{} go t.Run("TailStep", func(t *testing.T) { outputDataMutex.Lock() go outputDataMutex.Unlock() output, err := backend.TailStep(ctx, step, taskUUID) require.NoError(t, err) assert.NotNil(t, output) // Read output outputData, err = io.ReadAll(output) require.NoError(t, err) }) // Wait for step to finish t.Run("TestWaitStep", func(t *testing.T) { time.Sleep(time.Second / 5) // needed to prevent race condition on outputData state, err := backend.WaitStep(ctx, step, taskUUID) require.NoError(t, err) assert.True(t, state.Exited) assert.Equal(t, 0, state.ExitCode) }) // Verify output outputDataMutex.Lock() go outputDataMutex.Unlock() outputLines := strings.Split(strings.TrimSpace(string(outputData)), "\n") require.Truef(t, len(outputLines) > 3, "output of lines must be bigger than 3 at least but we got: %#v", outputLines) // we first test output without environments wantBeforeEnvs := []string{ "+ echo hello", "hello", "+ env", } gotBeforeEnvs := outputLines[:len(wantBeforeEnvs)] assert.Equal(t, wantBeforeEnvs, gotBeforeEnvs) // we filter out nixos specific stuff catched up in env output gotEnvs := slices.DeleteFunc(outputLines[len(wantBeforeEnvs):], func(s string) bool { return strings.HasPrefix(s, "_=") || strings.HasPrefix(s, "SHLVL=") }) assert.ElementsMatch(t, []string{ "PWD=" + state.baseDir + "/workspace", "USERPROFILE=" + state.baseDir + "/home", "TEST_VAR=test_value", "HOME=" + state.baseDir + "/home", "CI_WORKSPACE=" + state.baseDir + "/workspace", "PATH=" + strings.Join(path, ":"), }, gotEnvs) t.Run("TestDestroyStep", func(t *testing.T) { err := backend.DestroyStep(ctx, step, taskUUID) require.NoError(t, err) }) }) }) t.Run("run command in alternate unix shell", func(t *testing.T) { step := &types.Step{ UUID: "step-altshell", Name: "altshell", Type: types.StepTypeCommands, Image: "altsh", Commands: []string{"echo success"}, } err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) state, err := backend.WaitStep(ctx, step, taskUUID) require.NoError(t, err) assert.True(t, state.Exited) assert.Equal(t, 0, state.ExitCode) }) t.Run("command should fail", func(t *testing.T) { step := &types.Step{ UUID: "step-fail", Name: "fail-step", Type: types.StepTypeCommands, Image: "sh", Commands: []string{"exit 1"}, } err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) state, err := backend.WaitStep(ctx, step, taskUUID) require.NoError(t, err) assert.True(t, state.Exited) assert.Equal(t, 1, state.ExitCode) }) t.Run("WaitStep", func(t *testing.T) { t.Run("step not found", func(t *testing.T) { step := &types.Step{ UUID: "nonexistent-step", Name: "missing", } _, err = backend.WaitStep(ctx, step, taskUUID) assert.Error(t, err) assert.Contains(t, err.Error(), "not found") }) }) t.Run("type plugin", func(t *testing.T) { step := &types.Step{ UUID: "step-plugin-1", Name: "test-plugin", Type: types.StepTypePlugin, Image: "echo", // Use a binary that exists Environment: map[string]string{}, } t.Run("start", func(t *testing.T) { err = backend.StartStep(ctx, step, taskUUID) require.NoError(t, err) // Verify command was started state, err := backend.getStepState(taskUUID, step.UUID) require.NoError(t, err) assert.NotEqualf(t, 0, state.cmd.Process.Pid, "expect an pid of the process") }) }) t.Run("type unsupported", func(t *testing.T) { step := &types.Step{ UUID: "step-unsupported", Name: "test-unsupported", Type: "unsupported-type", } t.Run("start", func(t *testing.T) { err = backend.StartStep(ctx, step, taskUUID) assert.ErrorIs(t, err, ErrUnsupportedStepType) }) }) // Cleanup assert.NoError(t, backend.DestroyWorkflow(ctx, &types.Config{}, taskUUID)) } func TestStateManagement(t *testing.T) { backend, _ := New().(*local) t.Run("save and get state", func(t *testing.T) { taskUUID := "test-state-uuid" state := &workflowState{ baseDir: "/tmp/test", homeDir: "/tmp/test/2home", workspaceDir: "/tmp/test/2workspace", } backend.workflows.Store(taskUUID, state) retrieved, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) assert.Equal(t, state.baseDir, retrieved.baseDir) assert.Equal(t, state.homeDir, retrieved.homeDir) assert.Equal(t, state.workspaceDir, retrieved.workspaceDir) }) t.Run("get nonexistent state", func(t *testing.T) { _, err := backend.getWorkflowState("nonexistent-uuid") assert.ErrorIs(t, err, ErrWorkflowStateNotFound) }) t.Run("delete state", func(t *testing.T) { taskUUID := "test-delete-uuid" state := &workflowState{} backend.workflows.Store(taskUUID, state) // Verify state exists _, err := backend.getWorkflowState(taskUUID) require.NoError(t, err) // Delete state backend.workflows.Delete(taskUUID) // Verify state is gone _, err = backend.getWorkflowState(taskUUID) assert.ErrorIs(t, err, ErrWorkflowStateNotFound) }) } func TestConcurrentWorkflows(t *testing.T) { backend, _ := New().(*local) backend.tempDir = t.TempDir() ctx := context.Background() // Create multiple workflows concurrently taskUUIDs := []string{"task-1", "task-2", "task-3"} for _, uuid := range taskUUIDs { err := backend.SetupWorkflow(ctx, &types.Config{}, uuid) require.NoError(t, err) } counter := atomic.Int32{} counter.Store(0) for _, uuid := range taskUUIDs { go t.Run("start step in "+uuid, func(t *testing.T) { for i := 0; i < 3; i++ { counter.Store(counter.Load() + 1) step := &types.Step{ UUID: fmt.Sprintf("step-%s-%d", uuid, i), Name: fmt.Sprintf("step-name-%s-%d", uuid, i), Type: types.StepTypePlugin, Image: "sh", Commands: []string{fmt.Sprintf("echo %s %d", uuid, i)}, Environment: map[string]string{}, } require.NoError(t, backend.StartStep(ctx, step, uuid)) _, err := backend.WaitStep(ctx, step, uuid) require.NoError(t, err) counter.Store(counter.Load() - 1) } }) } // Verify all states exist for _, uuid := range taskUUIDs { state, err := backend.getWorkflowState(uuid) require.NoError(t, err) assert.NotNil(t, state) } failSave := 0 loop: for { if failSave == 1000 { // wait max 1s t.Log("failSave was hit") t.FailNow() } failSave++ select { case <-time.After(time.Millisecond): if count := counter.Load(); count == 0 { break loop } else { t.Logf("count at: %d", count) } case <-ctx.Done(): return } } // Cleanup all workflows for _, uuid := range taskUUIDs { // Cleanup all steps for i := 0; i < 3; i++ { stepUUID := fmt.Sprintf("step-%s-%d", uuid, i) assert.NoError(t, backend.DestroyStep(ctx, &types.Step{UUID: stepUUID}, uuid)) } // finish with workflow cleanup err := backend.DestroyWorkflow(ctx, &types.Config{}, uuid) require.NoError(t, err) } // Verify all states are deleted for _, uuid := range taskUUIDs { _, err := backend.getWorkflowState(uuid) assert.ErrorIs(t, err, ErrWorkflowStateNotFound) } } ================================================ FILE: pipeline/backend/local/plugin.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package local import ( "context" "fmt" "os/exec" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // execPlugin use step.Image as exec binary. func (e *local) execPlugin(ctx context.Context, step *types.Step, state *workflowState, env []string) error { binary, err := exec.LookPath(step.Image) if err != nil { return fmt.Errorf("lookup plugin binary: %w", err) } cmd := exec.CommandContext(ctx, binary) cmd.Env = env cmd.Dir = state.workspaceDir reader, err := cmd.StdoutPipe() if err != nil { return err } // Get output and redirect Stderr to Stdout cmd.Stderr = cmd.Stdout // Save state state.stepState.Store(step.UUID, &stepState{ cmd: cmd, output: reader, }) return cmd.Start() } ================================================ FILE: pipeline/backend/types/auth.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // Auth defines registry authentication credentials. type Auth struct { Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` } ================================================ FILE: pipeline/backend/types/backend.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package types defines the Backend interface and related types for // executing Woodpecker CI workflows across different runtime environments. package types import ( "context" "io" "github.com/urfave/cli/v3" ) // Backend defines the mechanism for orchestrating workflows and their steps. // // A Backend instance is created once per agent and must handle multiple // workflows concurrently, depending on the configured parallel workflow // capacity. Each workflow may have multiple steps executing concurrently. // // Thread Safety and Isolation: // // - Each workflow must have a unique taskUUID // - Backend implementations must use taskUUID to isolate workflow resources // - A single Backend instance must safely handle multiple concurrent workflows // - Workflow functions may be called concurrently for different workflows // - Step functions must be safe to call concurrently for different steps, // even across different workflows // // Intended execution flow: // // 1. Initialization (once per backend instance): // - Name() returns backend identifier // - IsAvailable() checks environment compatibility // - Flags() registers configuration options // - Load() initializes the backend instance // // 2. Workflow setup (once per workflow, may be called concurrently): // - SetupWorkflow() creates isolated environment for the workflow // // 3. Step execution (once per step, may run concurrently): // - StartStep() launches the step // - TailStep() streams logs (async, in background) // - WaitStep() blocks until completion // - DestroyStep() cleans up step resources // // 4. Workflow cleanup (once per workflow, may be called concurrently): // - DestroyWorkflow() removes workflow environment type Backend interface { // Name returns the unique identifier of the backend implementation. // Examples: "docker", "kubernetes", "local", "dummy" Name() string // IsAvailable checks if the backend is available and can be used in the // current environment. For example, a Docker backend would check if the // Docker daemon is accessible. IsAvailable(ctx context.Context) bool // Flags returns the configuration flags specific to this backend. // Are used to configure backend-specific behavior // (e.g., Docker socket path, Kubernetes namespace). Flags() []cli.Flag // Load initializes the backend engine and returns metadata about its // capabilities and configuration. // This is called once after flags are parsed. // The backend must be ready to handle multiple concurrent workflows // after Load completes successfully. Load(ctx context.Context) (*BackendInfo, error) // SetupWorkflow prepares the execution environment for a new workflow. // This is called exactly once per workflow, before any steps are started. // The taskUUID uniquely identifies this workflow and must be used to // isolate this workflow's resources from other concurrent workflows. // // Implementations should: // - Create isolated workspaces, networks, or namespaces // - Initialize shared volumes or storage // - Ensure the setup doesn't interfere with other running workflows // // This function may be called concurrently for different workflows. // Implementations must be thread-safe and handle concurrent workflow setup. SetupWorkflow(ctx context.Context, conf *Config, taskUUID string) error // StartStep set up and begins execution of a workflow step. // This may be called concurrently for multiple steps within the same // workflow, depending on the dependency graph. // // Implementations should: // - Start the step's container/process/pod // - Use taskUUID to associate the step with its workflow // - Ensure steps can run independently without blocking each other // - Handle different step types (commands, plugins, services, cache, clone) // // The step's UUID uniquely identifies it within the workflow. // This function must be thread-safe for concurrent calls. StartStep(ctx context.Context, step *Step, taskUUID string) error // TailStep streams the step's logs back to the caller. // This is started in a background goroutine immediately after // StartStep, before WaitStep is called. // // The returned io.ReadCloser should: // - Stream logs as they are produced by the step // - Remain open until the step completes or is destroyed // // The reader will be closed by the caller when no longer needed, which // may be after WaitStep returns or during DestroyStep. // This function must be thread-safe for concurrent calls. TailStep(ctx context.Context, step *Step, taskUUID string) (io.ReadCloser, error) // WaitStep blocks until the step completes and returns its final state. // This is called after StartStep and TailStep while TailStep is // streaming logs in the background. // // Returns: // - State.ExitCode: The step's exit code (0 for success, non-zero for failure) // - State.Error: Any error that occurred during step execution // - State.Exited: Timestamp when the step completed // // The TailStep reader may be closed either when WaitStep completes or // during DestroyStep - implementations should handle both cases. // This function must be thread-safe for concurrent calls. WaitStep(ctx context.Context, step *Step, taskUUID string) (*State, error) // DestroyStep cleans up resources associated with a step. // This is called after WaitStep completes, or if the workflow is canceled. // // Implementations should: // - Stop the step if still running // - Clean up step-specific resources (containers, processes) // - Close any open log streams // - Not affect other steps in the same or other workflows // - Must not fail if already invoked once // // Must be safe to call even if StartStep failed or the step was never started. // This function must be thread-safe for concurrent calls. DestroyStep(ctx context.Context, step *Step, taskUUID string) error // DestroyWorkflow cleans up all workflow-level resources. // // Implementations should: // - Destroy steps still running in the background (detached steps and services) // - Remove workflow-specific workspaces, networks, or namespaces // - Clean up shared volumes or storage // - Ensure complete cleanup so the taskUUID can be reused later // - Not affect other workflows that may be running concurrently // // Must be safe to call even if SetupWorkflow failed. // This function may be called concurrently for different workflows // and must be thread-safe. DestroyWorkflow(ctx context.Context, conf *Config, taskUUID string) error } // BackendInfo represents the reported information of a loaded backend. type BackendInfo struct { Platform string } ================================================ FILE: pipeline/backend/types/config.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // Config defines the runtime configuration of a workflow. type Config struct { Stages []*Stage `json:"pipeline"` // workflow stages Network string `json:"network"` // network definition Volume string `json:"volume"` // volume definition Secrets []*Secret `json:"secrets"` // secret definitions } // CliCommand is the context key to pass cli context to backends if needed. var CliCommand contextKey // contextKey is just an empty struct. It exists so CliCommand can be // an immutable public variable with a unique type. It's immutable // because nobody else can create a ContextKey, being unexported. type contextKey struct{} ================================================ FILE: pipeline/backend/types/conn.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // Conn defines a container network connection. type Conn struct { Name string `json:"name"` Aliases []string `json:"aliases"` } ================================================ FILE: pipeline/backend/types/errors.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import "errors" var ErrNoCliContextFound = errors.New("no CliContext in context found") ================================================ FILE: pipeline/backend/types/mocks/mock_Backend.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" "io" mock "github.com/stretchr/testify/mock" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // NewMockBackend creates a new instance of MockBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockBackend(t interface { mock.TestingT Cleanup(func()) }) *MockBackend { mock := &MockBackend{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockBackend is an autogenerated mock type for the Backend type type MockBackend struct { mock.Mock } type MockBackend_Expecter struct { mock *mock.Mock } func (_m *MockBackend) EXPECT() *MockBackend_Expecter { return &MockBackend_Expecter{mock: &_m.Mock} } // DestroyStep provides a mock function for the type MockBackend func (_mock *MockBackend) DestroyStep(ctx context.Context, step *types.Step, taskUUID string) error { ret := _mock.Called(ctx, step, taskUUID) if len(ret) == 0 { panic("no return value specified for DestroyStep") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) error); ok { r0 = returnFunc(ctx, step, taskUUID) } else { r0 = ret.Error(0) } return r0 } // MockBackend_DestroyStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DestroyStep' type MockBackend_DestroyStep_Call struct { *mock.Call } // DestroyStep is a helper method to define mock.On call // - ctx context.Context // - step *types.Step // - taskUUID string func (_e *MockBackend_Expecter) DestroyStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_DestroyStep_Call { return &MockBackend_DestroyStep_Call{Call: _e.mock.On("DestroyStep", ctx, step, taskUUID)} } func (_c *MockBackend_DestroyStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_DestroyStep_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.Step if args[1] != nil { arg1 = args[1].(*types.Step) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockBackend_DestroyStep_Call) Return(err error) *MockBackend_DestroyStep_Call { _c.Call.Return(err) return _c } func (_c *MockBackend_DestroyStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) error) *MockBackend_DestroyStep_Call { _c.Call.Return(run) return _c } // DestroyWorkflow provides a mock function for the type MockBackend func (_mock *MockBackend) DestroyWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error { ret := _mock.Called(ctx, conf, taskUUID) if len(ret) == 0 { panic("no return value specified for DestroyWorkflow") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Config, string) error); ok { r0 = returnFunc(ctx, conf, taskUUID) } else { r0 = ret.Error(0) } return r0 } // MockBackend_DestroyWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DestroyWorkflow' type MockBackend_DestroyWorkflow_Call struct { *mock.Call } // DestroyWorkflow is a helper method to define mock.On call // - ctx context.Context // - conf *types.Config // - taskUUID string func (_e *MockBackend_Expecter) DestroyWorkflow(ctx interface{}, conf interface{}, taskUUID interface{}) *MockBackend_DestroyWorkflow_Call { return &MockBackend_DestroyWorkflow_Call{Call: _e.mock.On("DestroyWorkflow", ctx, conf, taskUUID)} } func (_c *MockBackend_DestroyWorkflow_Call) Run(run func(ctx context.Context, conf *types.Config, taskUUID string)) *MockBackend_DestroyWorkflow_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.Config if args[1] != nil { arg1 = args[1].(*types.Config) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockBackend_DestroyWorkflow_Call) Return(err error) *MockBackend_DestroyWorkflow_Call { _c.Call.Return(err) return _c } func (_c *MockBackend_DestroyWorkflow_Call) RunAndReturn(run func(ctx context.Context, conf *types.Config, taskUUID string) error) *MockBackend_DestroyWorkflow_Call { _c.Call.Return(run) return _c } // Flags provides a mock function for the type MockBackend func (_mock *MockBackend) Flags() []cli.Flag { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for Flags") } var r0 []cli.Flag if returnFunc, ok := ret.Get(0).(func() []cli.Flag); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]cli.Flag) } } return r0 } // MockBackend_Flags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Flags' type MockBackend_Flags_Call struct { *mock.Call } // Flags is a helper method to define mock.On call func (_e *MockBackend_Expecter) Flags() *MockBackend_Flags_Call { return &MockBackend_Flags_Call{Call: _e.mock.On("Flags")} } func (_c *MockBackend_Flags_Call) Run(run func()) *MockBackend_Flags_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockBackend_Flags_Call) Return(flags []cli.Flag) *MockBackend_Flags_Call { _c.Call.Return(flags) return _c } func (_c *MockBackend_Flags_Call) RunAndReturn(run func() []cli.Flag) *MockBackend_Flags_Call { _c.Call.Return(run) return _c } // IsAvailable provides a mock function for the type MockBackend func (_mock *MockBackend) IsAvailable(ctx context.Context) bool { ret := _mock.Called(ctx) if len(ret) == 0 { panic("no return value specified for IsAvailable") } var r0 bool if returnFunc, ok := ret.Get(0).(func(context.Context) bool); ok { r0 = returnFunc(ctx) } else { r0 = ret.Get(0).(bool) } return r0 } // MockBackend_IsAvailable_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAvailable' type MockBackend_IsAvailable_Call struct { *mock.Call } // IsAvailable is a helper method to define mock.On call // - ctx context.Context func (_e *MockBackend_Expecter) IsAvailable(ctx interface{}) *MockBackend_IsAvailable_Call { return &MockBackend_IsAvailable_Call{Call: _e.mock.On("IsAvailable", ctx)} } func (_c *MockBackend_IsAvailable_Call) Run(run func(ctx context.Context)) *MockBackend_IsAvailable_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } run( arg0, ) }) return _c } func (_c *MockBackend_IsAvailable_Call) Return(b bool) *MockBackend_IsAvailable_Call { _c.Call.Return(b) return _c } func (_c *MockBackend_IsAvailable_Call) RunAndReturn(run func(ctx context.Context) bool) *MockBackend_IsAvailable_Call { _c.Call.Return(run) return _c } // Load provides a mock function for the type MockBackend func (_mock *MockBackend) Load(ctx context.Context) (*types.BackendInfo, error) { ret := _mock.Called(ctx) if len(ret) == 0 { panic("no return value specified for Load") } var r0 *types.BackendInfo var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context) (*types.BackendInfo, error)); ok { return returnFunc(ctx) } if returnFunc, ok := ret.Get(0).(func(context.Context) *types.BackendInfo); ok { r0 = returnFunc(ctx) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*types.BackendInfo) } } if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { r1 = returnFunc(ctx) } else { r1 = ret.Error(1) } return r0, r1 } // MockBackend_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' type MockBackend_Load_Call struct { *mock.Call } // Load is a helper method to define mock.On call // - ctx context.Context func (_e *MockBackend_Expecter) Load(ctx interface{}) *MockBackend_Load_Call { return &MockBackend_Load_Call{Call: _e.mock.On("Load", ctx)} } func (_c *MockBackend_Load_Call) Run(run func(ctx context.Context)) *MockBackend_Load_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } run( arg0, ) }) return _c } func (_c *MockBackend_Load_Call) Return(backendInfo *types.BackendInfo, err error) *MockBackend_Load_Call { _c.Call.Return(backendInfo, err) return _c } func (_c *MockBackend_Load_Call) RunAndReturn(run func(ctx context.Context) (*types.BackendInfo, error)) *MockBackend_Load_Call { _c.Call.Return(run) return _c } // Name provides a mock function for the type MockBackend func (_mock *MockBackend) Name() string { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for Name") } var r0 string if returnFunc, ok := ret.Get(0).(func() string); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(string) } return r0 } // MockBackend_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' type MockBackend_Name_Call struct { *mock.Call } // Name is a helper method to define mock.On call func (_e *MockBackend_Expecter) Name() *MockBackend_Name_Call { return &MockBackend_Name_Call{Call: _e.mock.On("Name")} } func (_c *MockBackend_Name_Call) Run(run func()) *MockBackend_Name_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockBackend_Name_Call) Return(s string) *MockBackend_Name_Call { _c.Call.Return(s) return _c } func (_c *MockBackend_Name_Call) RunAndReturn(run func() string) *MockBackend_Name_Call { _c.Call.Return(run) return _c } // SetupWorkflow provides a mock function for the type MockBackend func (_mock *MockBackend) SetupWorkflow(ctx context.Context, conf *types.Config, taskUUID string) error { ret := _mock.Called(ctx, conf, taskUUID) if len(ret) == 0 { panic("no return value specified for SetupWorkflow") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Config, string) error); ok { r0 = returnFunc(ctx, conf, taskUUID) } else { r0 = ret.Error(0) } return r0 } // MockBackend_SetupWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetupWorkflow' type MockBackend_SetupWorkflow_Call struct { *mock.Call } // SetupWorkflow is a helper method to define mock.On call // - ctx context.Context // - conf *types.Config // - taskUUID string func (_e *MockBackend_Expecter) SetupWorkflow(ctx interface{}, conf interface{}, taskUUID interface{}) *MockBackend_SetupWorkflow_Call { return &MockBackend_SetupWorkflow_Call{Call: _e.mock.On("SetupWorkflow", ctx, conf, taskUUID)} } func (_c *MockBackend_SetupWorkflow_Call) Run(run func(ctx context.Context, conf *types.Config, taskUUID string)) *MockBackend_SetupWorkflow_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.Config if args[1] != nil { arg1 = args[1].(*types.Config) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockBackend_SetupWorkflow_Call) Return(err error) *MockBackend_SetupWorkflow_Call { _c.Call.Return(err) return _c } func (_c *MockBackend_SetupWorkflow_Call) RunAndReturn(run func(ctx context.Context, conf *types.Config, taskUUID string) error) *MockBackend_SetupWorkflow_Call { _c.Call.Return(run) return _c } // StartStep provides a mock function for the type MockBackend func (_mock *MockBackend) StartStep(ctx context.Context, step *types.Step, taskUUID string) error { ret := _mock.Called(ctx, step, taskUUID) if len(ret) == 0 { panic("no return value specified for StartStep") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) error); ok { r0 = returnFunc(ctx, step, taskUUID) } else { r0 = ret.Error(0) } return r0 } // MockBackend_StartStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartStep' type MockBackend_StartStep_Call struct { *mock.Call } // StartStep is a helper method to define mock.On call // - ctx context.Context // - step *types.Step // - taskUUID string func (_e *MockBackend_Expecter) StartStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_StartStep_Call { return &MockBackend_StartStep_Call{Call: _e.mock.On("StartStep", ctx, step, taskUUID)} } func (_c *MockBackend_StartStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_StartStep_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.Step if args[1] != nil { arg1 = args[1].(*types.Step) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockBackend_StartStep_Call) Return(err error) *MockBackend_StartStep_Call { _c.Call.Return(err) return _c } func (_c *MockBackend_StartStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) error) *MockBackend_StartStep_Call { _c.Call.Return(run) return _c } // TailStep provides a mock function for the type MockBackend func (_mock *MockBackend) TailStep(ctx context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error) { ret := _mock.Called(ctx, step, taskUUID) if len(ret) == 0 { panic("no return value specified for TailStep") } var r0 io.ReadCloser var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) (io.ReadCloser, error)); ok { return returnFunc(ctx, step, taskUUID) } if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) io.ReadCloser); ok { r0 = returnFunc(ctx, step, taskUUID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(io.ReadCloser) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *types.Step, string) error); ok { r1 = returnFunc(ctx, step, taskUUID) } else { r1 = ret.Error(1) } return r0, r1 } // MockBackend_TailStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TailStep' type MockBackend_TailStep_Call struct { *mock.Call } // TailStep is a helper method to define mock.On call // - ctx context.Context // - step *types.Step // - taskUUID string func (_e *MockBackend_Expecter) TailStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_TailStep_Call { return &MockBackend_TailStep_Call{Call: _e.mock.On("TailStep", ctx, step, taskUUID)} } func (_c *MockBackend_TailStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_TailStep_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.Step if args[1] != nil { arg1 = args[1].(*types.Step) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockBackend_TailStep_Call) Return(readCloser io.ReadCloser, err error) *MockBackend_TailStep_Call { _c.Call.Return(readCloser, err) return _c } func (_c *MockBackend_TailStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) (io.ReadCloser, error)) *MockBackend_TailStep_Call { _c.Call.Return(run) return _c } // WaitStep provides a mock function for the type MockBackend func (_mock *MockBackend) WaitStep(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error) { ret := _mock.Called(ctx, step, taskUUID) if len(ret) == 0 { panic("no return value specified for WaitStep") } var r0 *types.State var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) (*types.State, error)); ok { return returnFunc(ctx, step, taskUUID) } if returnFunc, ok := ret.Get(0).(func(context.Context, *types.Step, string) *types.State); ok { r0 = returnFunc(ctx, step, taskUUID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*types.State) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *types.Step, string) error); ok { r1 = returnFunc(ctx, step, taskUUID) } else { r1 = ret.Error(1) } return r0, r1 } // MockBackend_WaitStep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitStep' type MockBackend_WaitStep_Call struct { *mock.Call } // WaitStep is a helper method to define mock.On call // - ctx context.Context // - step *types.Step // - taskUUID string func (_e *MockBackend_Expecter) WaitStep(ctx interface{}, step interface{}, taskUUID interface{}) *MockBackend_WaitStep_Call { return &MockBackend_WaitStep_Call{Call: _e.mock.On("WaitStep", ctx, step, taskUUID)} } func (_c *MockBackend_WaitStep_Call) Run(run func(ctx context.Context, step *types.Step, taskUUID string)) *MockBackend_WaitStep_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.Step if args[1] != nil { arg1 = args[1].(*types.Step) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockBackend_WaitStep_Call) Return(state *types.State, err error) *MockBackend_WaitStep_Call { _c.Call.Return(state, err) return _c } func (_c *MockBackend_WaitStep_Call) RunAndReturn(run func(ctx context.Context, step *types.Step, taskUUID string) (*types.State, error)) *MockBackend_WaitStep_Call { _c.Call.Return(run) return _c } ================================================ FILE: pipeline/backend/types/network.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types type Port struct { Number uint16 `json:"number,omitempty"` Protocol string `json:"protocol,omitempty"` } type HostAlias struct { Name string `json:"name,omitempty"` IP string `json:"ip,omitempty"` } ================================================ FILE: pipeline/backend/types/secret.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // Secret defines a runtime secret. type Secret struct { Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` } ================================================ FILE: pipeline/backend/types/stage.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // Stage denotes a collection of one or more steps. type Stage struct { Steps []*Step `json:"steps,omitempty"` } ================================================ FILE: pipeline/backend/types/state.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // State defines a container state. type State struct { // Unix start time Started int64 `json:"started"` // Container exit code ExitCode int `json:"exit_code"` // Container exited, true or false Exited bool `json:"exited"` // Step was skipped by the runtime (OnSuccess/OnFailure filter) Skipped bool `json:"skipped"` // Container is oom killed, true or false // TODO (6024): well known errors as string enum into ./errors.go OOMKilled bool `json:"oom_killed"` // Container error Error error } ================================================ FILE: pipeline/backend/types/step.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // Step defines a container process. type Step struct { Name string `json:"name"` OrgID int64 `json:"org_id,omitempty"` UUID string `json:"uuid"` Type StepType `json:"type,omitempty"` Image string `json:"image,omitempty"` Pull bool `json:"pull,omitempty"` Detached bool `json:"detach,omitempty"` Privileged bool `json:"privileged,omitempty"` WorkingDir string `json:"working_dir,omitempty"` WorkspaceBase string `json:"workspace_base,omitempty"` Environment map[string]string `json:"environment,omitempty"` SecretMapping map[string]string `json:"secret_mapping,omitempty"` Entrypoint []string `json:"entrypoint,omitempty"` Commands []string `json:"commands,omitempty"` ExtraHosts []HostAlias `json:"extra_hosts,omitempty"` Volumes []string `json:"volumes,omitempty"` Tmpfs []string `json:"tmpfs,omitempty"` Devices []string `json:"devices,omitempty"` Networks []Conn `json:"networks,omitempty"` DNS []string `json:"dns,omitempty"` DNSSearch []string `json:"dns_search,omitempty"` OnFailure bool `json:"on_failure,omitempty"` OnSuccess bool `json:"on_success,omitempty"` Failure string `json:"failure,omitempty"` AuthConfig Auth `json:"auth_config"` NetworkMode string `json:"network_mode,omitempty"` Ports []Port `json:"ports,omitempty"` BackendOptions map[string]any `json:"backend_options,omitempty"` WorkflowLabels map[string]string `json:"workflow_labels,omitempty"` } // StepType identifies the type of step. type StepType string const ( StepTypeClone StepType = "clone" StepTypeService StepType = "service" StepTypePlugin StepType = "plugin" StepTypeCommands StepType = "commands" StepTypeCache StepType = "cache" ) ================================================ FILE: pipeline/const.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline const ( ExitCodeKilled int = 137 // Store no more than 1mb in a log-line as 4mb is the limit of a grpc message // and log-lines needs to be parsed by the browsers later on. MaxLogLineLength int = 1 * 1024 * 1024 // 1mb InternalLabelPrefix string = "woodpecker-ci.org" LabelForgeRemoteID string = InternalLabelPrefix + "/forge-id" LabelRepoForgeID string = InternalLabelPrefix + "/repo-forge-id" LabelRepoID string = InternalLabelPrefix + "/repo-id" LabelRepoName string = InternalLabelPrefix + "/repo-name" LabelRepoFullName string = InternalLabelPrefix + "/repo-full-name" LabelBranch string = InternalLabelPrefix + "/branch" LabelOrgID string = InternalLabelPrefix + "/org-id" LabelFilterOrg string = "org-id" LabelFilterRepo string = "repo" LabelFilterPlatform string = "platform" LabelFilterHostname string = "hostname" LabelFilterBackend string = "backend" ) ================================================ FILE: pipeline/errors/linter.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package errors import ( "errors" "go.uber.org/multierr" ) type LinterErrorData struct { File string `json:"file"` Field string `json:"field"` } type DeprecationErrorData struct { File string `json:"file"` Field string `json:"field"` Docs string `json:"docs"` } type BadHabitErrorData struct { File string `json:"file"` Field string `json:"field"` Docs string `json:"docs"` } func GetLinterData(e *PipelineError) *LinterErrorData { if e.Type != PipelineErrorTypeLinter { return nil } if data, ok := e.Data.(*LinterErrorData); ok { return data } return nil } func GetPipelineErrors(err error) []*PipelineError { var pipelineErrors []*PipelineError for _, _err := range multierr.Errors(err) { var err *PipelineError if errors.As(_err, &err) { pipelineErrors = append(pipelineErrors, err) } else { pipelineErrors = append(pipelineErrors, &PipelineError{ Message: _err.Error(), Type: PipelineErrorTypeGeneric, }) } } return pipelineErrors } func HasBlockingErrors(err error) bool { if err == nil { return false } errs := GetPipelineErrors(err) for _, err := range errs { if !err.IsWarning { return true } } return false } ================================================ FILE: pipeline/errors/linter_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package errors_test import ( "errors" "testing" "github.com/stretchr/testify/assert" "go.uber.org/multierr" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" ) func TestGetPipelineErrors(t *testing.T) { t.Parallel() tests := []struct { title string err error expected []*pipeline_errors.PipelineError }{ { title: "nil error", err: nil, expected: nil, }, { title: "warning", err: &pipeline_errors.PipelineError{ IsWarning: true, }, expected: []*pipeline_errors.PipelineError{ { IsWarning: true, }, }, }, { title: "pipeline error", err: &pipeline_errors.PipelineError{ IsWarning: false, }, expected: []*pipeline_errors.PipelineError{ { IsWarning: false, }, }, }, { title: "multiple warnings", err: multierr.Combine( &pipeline_errors.PipelineError{ IsWarning: true, }, &pipeline_errors.PipelineError{ IsWarning: true, }, ), expected: []*pipeline_errors.PipelineError{ { IsWarning: true, }, { IsWarning: true, }, }, }, { title: "multiple errors and warnings", err: multierr.Combine( &pipeline_errors.PipelineError{ IsWarning: true, }, &pipeline_errors.PipelineError{ IsWarning: false, }, errors.New("some error"), ), expected: []*pipeline_errors.PipelineError{ { IsWarning: true, }, { IsWarning: false, }, { Type: pipeline_errors.PipelineErrorTypeGeneric, IsWarning: false, Message: "some error", }, }, }, } for _, test := range tests { assert.Equalf(t, pipeline_errors.GetPipelineErrors(test.err), test.expected, test.title) } } func TestHasBlockingErrors(t *testing.T) { t.Parallel() tests := []struct { title string err error expected bool }{ { title: "nil error", err: nil, expected: false, }, { title: "warning", err: &pipeline_errors.PipelineError{ IsWarning: true, }, expected: false, }, { title: "pipeline error", err: &pipeline_errors.PipelineError{ IsWarning: false, }, expected: true, }, { title: "multiple warnings", err: multierr.Combine( &pipeline_errors.PipelineError{ IsWarning: true, }, &pipeline_errors.PipelineError{ IsWarning: true, }, ), expected: false, }, { title: "multiple errors and warnings", err: multierr.Combine( &pipeline_errors.PipelineError{ IsWarning: true, }, &pipeline_errors.PipelineError{ IsWarning: false, }, errors.New("some error"), ), expected: true, }, } for _, test := range tests { assert.Equal(t, test.expected, pipeline_errors.HasBlockingErrors(test.err)) } } ================================================ FILE: pipeline/errors/pipeline.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package errors import ( "fmt" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) type PipelineErrorType string const ( PipelineErrorTypeLinter PipelineErrorType = "linter" // some error with the config syntax PipelineErrorTypeDeprecation PipelineErrorType = "deprecation" // using some deprecated feature PipelineErrorTypeCompiler PipelineErrorType = "compiler" // some error with the config semantics PipelineErrorTypeGeneric PipelineErrorType = "generic" // some generic error PipelineErrorTypeBadHabit PipelineErrorType = "bad_habit" // some bad-habit error ) type PipelineError struct { Type PipelineErrorType `json:"type"` Message string `json:"message"` IsWarning bool `json:"is_warning"` Data any `json:"data"` } func (e *PipelineError) Error() string { return fmt.Sprintf("[%s] %s", e.Type, e.Message) } type ErrInvalidWorkflowSetup struct { Err error Step *backend_types.Step } func (e *ErrInvalidWorkflowSetup) Error() string { if e.Step != nil { return fmt.Sprintf("error in workflow setup step '%s': %v", e.Step.Name, e.Err) } return fmt.Sprintf("error in workflow setup: %v", e.Err) } ================================================ FILE: pipeline/errors/runtime.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package errors import ( "errors" "fmt" ) var ( // ErrSkip is used as a return value when container execution should be // skipped at runtime. It is not returned as an error by any function. ErrSkip = errors.New("Skipped") // ErrCancel is used as a return value when the container execution receives // a cancellation signal from the context. ErrCancel = errors.New("Canceled") ) // An ExitError reports an unsuccessful exit. type ExitError struct { UUID string Code int } // Error returns the error message in string format. func (e *ExitError) Error() string { return fmt.Sprintf("uuid=%s: exit code %d", e.UUID, e.Code) } // An OomError reports the process received an OOMKill from the kernel. type OomError struct { UUID string Code int } // Error returns the error message in string format. func (e *OomError) Error() string { return fmt.Sprintf("uuid=%s: received oom kill", e.UUID) } ================================================ FILE: pipeline/frontend/metadata/const.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata type Event string // Event types corresponding to forge hooks. const ( EventPush Event = "push" EventPull Event = "pull_request" EventPullClosed Event = "pull_request_closed" EventPullMetadata Event = "pull_request_metadata" EventTag Event = "tag" EventRelease Event = "release" EventDeploy Event = "deployment" EventCron Event = "cron" EventManual Event = "manual" ) func (event Event) IsPull() bool { switch event { case EventPull, EventPullClosed, EventPullMetadata: return true } return false } type Failure string // Different ways to handle failure states. const ( FailureIgnore Failure = "ignore" FailureFail Failure = "fail" FailureCancel Failure = "cancel" ) ================================================ FILE: pipeline/frontend/metadata/drone_compatibility.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata // SetDroneEnviron set dedicated to DroneCI environment vars as compatibility // layer. Main purpose is to be compatible with drone plugins. func SetDroneEnviron(env map[string]string) { // webhook copyEnv("CI_COMMIT_BRANCH", "DRONE_BRANCH", env) copyEnv("CI_COMMIT_PULL_REQUEST", "DRONE_PULL_REQUEST", env) copyEnv("CI_COMMIT_PULL_REQUEST", "PULLREQUEST_DRONE_PULL_REQUEST", env) copyEnv("CI_COMMIT_TAG", "DRONE_TAG", env) copyEnv("CI_COMMIT_SOURCE_BRANCH", "DRONE_SOURCE_BRANCH", env) copyEnv("CI_COMMIT_TARGET_BRANCH", "DRONE_TARGET_BRANCH", env) // pipeline copyEnv("CI_PIPELINE_NUMBER", "DRONE_BUILD_NUMBER", env) copyEnv("CI_PIPELINE_PARENT", "DRONE_BUILD_PARENT", env) copyEnv("CI_PIPELINE_EVENT", "DRONE_BUILD_EVENT", env) copyEnv("CI_PIPELINE_URL", "DRONE_BUILD_LINK", env) copyEnv("CI_PIPELINE_CREATED", "DRONE_BUILD_CREATED", env) copyEnv("CI_PIPELINE_STARTED", "DRONE_BUILD_STARTED", env) // commit copyEnv("CI_COMMIT_SHA", "DRONE_COMMIT", env) copyEnv("CI_COMMIT_SHA", "DRONE_COMMIT_SHA", env) copyEnv("CI_PREV_COMMIT_SHA", "DRONE_COMMIT_BEFORE", env) copyEnv("CI_COMMIT_REF", "DRONE_COMMIT_REF", env) copyEnv("CI_COMMIT_BRANCH", "DRONE_COMMIT_BRANCH", env) copyEnv("CI_PIPELINE_FORGE_URL", "DRONE_COMMIT_LINK", env) copyEnv("CI_COMMIT_MESSAGE", "DRONE_COMMIT_MESSAGE", env) copyEnv("CI_COMMIT_AUTHOR", "DRONE_COMMIT_AUTHOR", env) copyEnv("CI_COMMIT_AUTHOR", "DRONE_COMMIT_AUTHOR_NAME", env) copyEnv("CI_COMMIT_AUTHOR_EMAIL", "DRONE_COMMIT_AUTHOR_EMAIL", env) copyEnv("CI_PIPELINE_AVATAR", "DRONE_COMMIT_AUTHOR_AVATAR", env) // repo copyEnv("CI_REPO", "DRONE_REPO", env) copyEnv("CI_REPO_OWNER", "DRONE_REPO_OWNER", env) copyEnv("CI_REPO_NAME", "DRONE_REPO_NAME", env) copyEnv("CI_REPO_URL", "DRONE_REPO_LINK", env) copyEnv("CI_REPO_DEFAULT_BRANCH", "DRONE_REPO_BRANCH", env) copyEnv("CI_REPO_PRIVATE", "DRONE_REPO_PRIVATE", env) // clone copyEnv("CI_REPO_CLONE_URL", "DRONE_REMOTE_URL", env) copyEnv("CI_REPO_CLONE_URL", "DRONE_GIT_HTTP_URL", env) // misc copyEnv("CI_SYSTEM_HOST", "DRONE_SYSTEM_HOST", env) copyEnv("CI_STEP_NUMBER", "DRONE_STEP_NUMBER", env) env["DRONE_BUILD_STATUS"] = "success" env["DRONE_REPO_SCM"] = "git" // some quirks // Legacy env var to prevent the plugin from throwing an error // when converting an empty string to a number // // plugins affected: "plugins/manifest" if env["CI_COMMIT_PULL_REQUEST"] == "" { env["PULLREQUEST_DRONE_PULL_REQUEST"] = "0" } } func copyEnv(woodpecker, drone string, env map[string]string) { var present bool var value string value, present = env[woodpecker] if present { env[drone] = value } } ================================================ FILE: pipeline/frontend/metadata/drone_compatibility_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata_test import ( "strings" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" ) func TestSetDroneEnvironOnPull(t *testing.T) { woodpeckerVars := `CI=woodpecker CI_COMMIT_AUTHOR=6543 CI_COMMIT_BRANCH=main CI_COMMIT_MESSAGE=fix testscript CI_COMMIT_PULL_REQUEST=9 CI_COMMIT_PULL_REQUEST_LABELS=tests,bugfix CI_COMMIT_REF=refs/pull/9/head CI_COMMIT_REFSPEC=fix_fail-on-err:main CI_COMMIT_SHA=a778b069d9f5992786d2db9be493b43868cfce76 CI_COMMIT_SOURCE_BRANCH=fix_fail-on-err CI_COMMIT_TARGET_BRANCH=main CI_MACHINE=7939910e431b CI_PIPELINE_CREATED=1685749339 CI_PIPELINE_EVENT=pull_request CI_PIPELINE_NUMBER=41 CI_PIPELINE_STARTED=1685749339 CI_PREV_COMMIT_AUTHOR=6543 CI_PREV_COMMIT_BRANCH=main CI_PREV_COMMIT_MESSAGE=Print filename and linenuber on fail CI_PREV_COMMIT_REF=refs/pull/13/head CI_PREV_COMMIT_REFSPEC=print_file_and_line:main CI_PREV_COMMIT_SHA=e246aff5a9466df2e522efc9007823a7496d9d41 CI_PREV_PIPELINE_CREATED=1685748680 CI_PREV_PIPELINE_EVENT=pull_request CI_PREV_PIPELINE_FINISHED=1685748704 CI_PREV_PIPELINE_NUMBER=40 CI_PREV_PIPELINE_STARTED=1685748680 CI_PREV_PIPELINE_STATUS=success CI_REPO=Epsilon_02/todo-checker CI_REPO_CLONE_URL=https://codeberg.org/Epsilon_02/todo-checker.git CI_REPO_DEFAULT_BRANCH=main CI_REPO_NAME=todo-checker CI_REPO_OWNER=Epsilon_02 CI_STEP_NAME=wp_01h1z7v5d1tskaqjexw0ng6w7d_0_step_3 CI_STEP_STARTED=1685749339 CI_SYSTEM_PLATFORM=linux/amd64 CI_SYSTEM_HOST=ci.codeberg.org CI_SYSTEM_NAME=woodpecker CI_SYSTEM_VERSION=next-dd644da3 CI_WORKFLOW_NAME=woodpecker CI_WORKFLOW_NUMBER=1 CI_WORKSPACE=/woodpecker/src/codeberg.org/Epsilon_02/todo-checker` droneVars := `DRONE_BRANCH=main DRONE_BUILD_CREATED=1685749339 DRONE_BUILD_EVENT=pull_request DRONE_BUILD_NUMBER=41 DRONE_BUILD_STARTED=1685749339 DRONE_BUILD_STATUS=success DRONE_COMMIT=a778b069d9f5992786d2db9be493b43868cfce76 DRONE_COMMIT_AUTHOR=6543 DRONE_COMMIT_AUTHOR_NAME=6543 DRONE_COMMIT_BEFORE=e246aff5a9466df2e522efc9007823a7496d9d41 DRONE_COMMIT_BRANCH=main DRONE_COMMIT_MESSAGE=fix testscript DRONE_COMMIT_REF=refs/pull/9/head DRONE_COMMIT_SHA=a778b069d9f5992786d2db9be493b43868cfce76 DRONE_GIT_HTTP_URL=https://codeberg.org/Epsilon_02/todo-checker.git DRONE_PULL_REQUEST=9 DRONE_REMOTE_URL=https://codeberg.org/Epsilon_02/todo-checker.git DRONE_REPO=Epsilon_02/todo-checker DRONE_REPO_BRANCH=main DRONE_REPO_NAME=todo-checker DRONE_REPO_OWNER=Epsilon_02 DRONE_REPO_SCM=git DRONE_SOURCE_BRANCH=fix_fail-on-err DRONE_SYSTEM_HOST=ci.codeberg.org DRONE_TARGET_BRANCH=main PULLREQUEST_DRONE_PULL_REQUEST=9` env := convertListToEnvMap(t, woodpeckerVars) metadata.SetDroneEnviron(env) // filter only new added env vars for k := range convertListToEnvMap(t, woodpeckerVars) { delete(env, k) } assert.EqualValues(t, convertListToEnvMap(t, droneVars), env) } func TestSetDroneEnvironOnPush(t *testing.T) { woodpeckerVars := `CI_COMMIT_AUTHOR=test CI_COMMIT_AUTHOR_EMAIL=test@noreply.localhost CI_COMMIT_BRANCH=main CI_COMMIT_MESSAGE=revert 9b2aed1392fc097ef7b027712977722fb004d463 CI_COMMIT_PULL_REQUEST= CI_COMMIT_PULL_REQUEST_LABELS= CI_COMMIT_REF=refs/heads/main CI_COMMIT_REFSPEC= CI_COMMIT_SHA=8826c98181353075bbeee8f99b400496488e3523 CI_COMMIT_SOURCE_BRANCH= CI_COMMIT_TAG= CI_COMMIT_TARGET_BRANCH= CI_FORGE_TYPE=gitea CI_FORGE_URL=http://1.2.3.4:3000 CI_MACHINE=hagalaz CI_PIPELINE_CREATED=1721328737 CI_PIPELINE_DEPLOY_TARGET= CI_PIPELINE_DEPLOY_TASK= CI_PIPELINE_EVENT=push CI_PIPELINE_FILES=[".woodpecker.yaml"] CI_PIPELINE_FORGE_URL=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523 CI_PIPELINE_NUMBER=24 CI_PIPELINE_PARENT=23 CI_PIPELINE_STARTED=1721328737 CI_PIPELINE_URL=http://1.2.3.4:8000/repos/2/pipeline/24 CI_PREV_COMMIT_AUTHOR=test CI_PREV_COMMIT_AUTHOR_EMAIL=test@noreply.localhost CI_PREV_COMMIT_BRANCH=main CI_PREV_COMMIT_MESSAGE=revert 9b2aed1392fc097ef7b027712977722fb004d463 CI_PREV_COMMIT_REF=refs/heads/main CI_PREV_COMMIT_REFSPEC= CI_PREV_COMMIT_SHA=8826c98181353075bbeee8f99b400496488e3523 CI_PREV_COMMIT_URL=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523 CI_PREV_COMMIT_SOURCE_BRANCH= CI_PREV_COMMIT_TARGET_BRANCH= CI_PREV_PIPELINE_CREATED=1721086039 CI_PREV_PIPELINE_DEPLOY_TARGET= CI_PREV_PIPELINE_DEPLOY_TASK= CI_PREV_PIPELINE_EVENT=push CI_PREV_PIPELINE_FINISHED=1721086056 CI_PREV_PIPELINE_FORGE_URL=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523 CI_PREV_PIPELINE_NUMBER=23 CI_PREV_PIPELINE_PARENT=0 CI_PREV_PIPELINE_STARTED=1721086039 CI_PREV_PIPELINE_STATUS=failure CI_PREV_PIPELINE_URL=http://1.2.3.4:8000/repos/2/pipeline/23 CI_REPO=test/woodpecker-test CI_REPO_CLONE_SSH_URL=user@1.2.3.4:test/woodpecker-test.git CI_REPO_CLONE_URL=http://1.2.3.4:3000/test/woodpecker-test.git CI_REPO_DEFAULT_BRANCH=main CI_REPO_NAME=woodpecker-test CI_REPO_OWNER=test CI_REPO_PRIVATE=false CI_REPO_REMOTE_ID=4 CI_REPO_TRUSTED=false CI_REPO_TRUSTED_NETWORK=false CI_REPO_TRUSTED_VOLUMES=false CI_REPO_TRUSTED_SECURITY=false CI_REPO_URL=http://1.2.3.4:3000/test/woodpecker-test CI_STEP_NAME= CI_STEP_NUMBER=0 CI_STEP_STARTED=1721328737 CI_STEP_URL=http://1.2.3.4:8000/repos/2/pipeline/24 CI_SYSTEM_HOST=1.2.3.4:8000 CI_SYSTEM_NAME=woodpecker CI_SYSTEM_PLATFORM=linux/amd64 CI_SYSTEM_URL=http://1.2.3.4:8000 CI_SYSTEM_VERSION=2.7.0 CI_WORKFLOW_NAME=woodpecker CI_WORKFLOW_NUMBER=1 CI_WORKSPACE=/usr/local/src/1.2.3.4/test/woodpecker-test` droneVars := `DRONE_BRANCH=main DRONE_BUILD_CREATED=1721328737 DRONE_BUILD_EVENT=push DRONE_BUILD_LINK=http://1.2.3.4:8000/repos/2/pipeline/24 DRONE_BUILD_NUMBER=24 DRONE_BUILD_PARENT=23 DRONE_BUILD_STARTED=1721328737 DRONE_BUILD_STATUS=success DRONE_COMMIT=8826c98181353075bbeee8f99b400496488e3523 DRONE_COMMIT_AUTHOR=test DRONE_COMMIT_AUTHOR_EMAIL=test@noreply.localhost DRONE_COMMIT_AUTHOR_NAME=test DRONE_COMMIT_BEFORE=8826c98181353075bbeee8f99b400496488e3523 DRONE_COMMIT_BRANCH=main DRONE_COMMIT_LINK=http://1.2.3.4:3000/test/woodpecker-test/commit/8826c98181353075bbeee8f99b400496488e3523 DRONE_COMMIT_MESSAGE=revert 9b2aed1392fc097ef7b027712977722fb004d463 DRONE_COMMIT_REF=refs/heads/main DRONE_COMMIT_SHA=8826c98181353075bbeee8f99b400496488e3523 DRONE_GIT_HTTP_URL=http://1.2.3.4:3000/test/woodpecker-test.git DRONE_PULL_REQUEST= DRONE_REMOTE_URL=http://1.2.3.4:3000/test/woodpecker-test.git DRONE_REPO=test/woodpecker-test DRONE_REPO_BRANCH=main DRONE_REPO_LINK=http://1.2.3.4:3000/test/woodpecker-test DRONE_REPO_NAME=woodpecker-test DRONE_REPO_OWNER=test DRONE_REPO_PRIVATE=false DRONE_REPO_SCM=git DRONE_SOURCE_BRANCH= DRONE_STEP_NUMBER=0 DRONE_SYSTEM_HOST=1.2.3.4:8000 DRONE_TAG= DRONE_TARGET_BRANCH= PULLREQUEST_DRONE_PULL_REQUEST=0` env := convertListToEnvMap(t, woodpeckerVars) metadata.SetDroneEnviron(env) // filter only new added env vars for k := range convertListToEnvMap(t, woodpeckerVars) { delete(env, k) } assert.EqualValues(t, convertListToEnvMap(t, droneVars), env) } func convertListToEnvMap(t *testing.T, list string) map[string]string { result := make(map[string]string) for _, s := range strings.Split(list, "\n") { before, after, _ := strings.Cut(strings.TrimSpace(s), "=") if before == "" { t.Fatal("helper function got invalid test data") } result[before] = after } return result } ================================================ FILE: pipeline/frontend/metadata/environment.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata import ( "encoding/json" "fmt" "path" "regexp" "strconv" "strings" "github.com/rs/zerolog/log" ) const ( initialEnvMapSize = 100 maxChangedFiles = 500 ) var pullRegexp = regexp.MustCompile(`\d+`) // Environ returns the metadata as a map of environment variables. func (m *Metadata) Environ() map[string]string { params := make(map[string]string, initialEnvMapSize) system := m.Sys setNonEmptyEnvVar(params, "CI", system.Name) setNonEmptyEnvVar(params, "CI_SYSTEM_NAME", system.Name) setNonEmptyEnvVar(params, "CI_SYSTEM_URL", system.URL) setNonEmptyEnvVar(params, "CI_SYSTEM_HOST", system.Host) setNonEmptyEnvVar(params, "CI_SYSTEM_PLATFORM", system.Platform) // will be set by pipeline platform option or by agent setNonEmptyEnvVar(params, "CI_SYSTEM_VERSION", system.Version) forge := m.Forge setNonEmptyEnvVar(params, "CI_FORGE_TYPE", forge.Type) setNonEmptyEnvVar(params, "CI_FORGE_URL", forge.URL) repo := m.Repo setNonEmptyEnvVar(params, "CI_REPO", path.Join(repo.Owner, repo.Name)) setNonEmptyEnvVar(params, "CI_REPO_NAME", repo.Name) setNonEmptyEnvVar(params, "CI_REPO_OWNER", repo.Owner) setNonEmptyEnvVar(params, "CI_REPO_REMOTE_ID", repo.RemoteID) setNonEmptyEnvVar(params, "CI_REPO_URL", repo.ForgeURL) setNonEmptyEnvVar(params, "CI_REPO_CLONE_URL", repo.CloneURL) setNonEmptyEnvVar(params, "CI_REPO_CLONE_SSH_URL", repo.CloneSSHURL) setNonEmptyEnvVar(params, "CI_REPO_DEFAULT_BRANCH", repo.Branch) setNonEmptyEnvVar(params, "CI_REPO_PRIVATE", strconv.FormatBool(repo.Private)) setNonEmptyEnvVar(params, "CI_REPO_TRUSTED_NETWORK", strconv.FormatBool(repo.Trusted.Network)) setNonEmptyEnvVar(params, "CI_REPO_TRUSTED_VOLUMES", strconv.FormatBool(repo.Trusted.Volumes)) setNonEmptyEnvVar(params, "CI_REPO_TRUSTED_SECURITY", strconv.FormatBool(repo.Trusted.Security)) // Deprecated remove in 4.x setNonEmptyEnvVar(params, "CI_REPO_TRUSTED", strconv.FormatBool(m.Repo.Trusted.Security && m.Repo.Trusted.Network && m.Repo.Trusted.Volumes)) pipeline := m.Curr setNonEmptyEnvVar(params, "CI_PIPELINE_NUMBER", strconv.FormatInt(pipeline.Number, 10)) setNonEmptyEnvVar(params, "CI_PIPELINE_PARENT", strconv.FormatInt(pipeline.Parent, 10)) setNonEmptyEnvVar(params, "CI_PIPELINE_EVENT", string(pipeline.Event)) setNonEmptyEnvVar(params, "CI_PIPELINE_EVENT_REASON", strings.Join(pipeline.EventReason, ",")) setNonEmptyEnvVar(params, "CI_PIPELINE_URL", m.getPipelineWebURL(pipeline, 0)) setNonEmptyEnvVar(params, "CI_PIPELINE_FORGE_URL", pipeline.ForgeURL) setNonEmptyEnvVar(params, "CI_PIPELINE_DEPLOY_TARGET", pipeline.DeployTo) setNonEmptyEnvVar(params, "CI_PIPELINE_DEPLOY_TASK", pipeline.DeployTask) setNonEmptyEnvVar(params, "CI_PIPELINE_CREATED", strconv.FormatInt(pipeline.Created, 10)) setNonEmptyEnvVar(params, "CI_PIPELINE_STARTED", strconv.FormatInt(pipeline.Started, 10)) setNonEmptyEnvVar(params, "CI_PIPELINE_AUTHOR", pipeline.Author) setNonEmptyEnvVar(params, "CI_PIPELINE_AVATAR", pipeline.Avatar) workflow := m.Workflow setNonEmptyEnvVar(params, "CI_WORKFLOW_NAME", workflow.Name) setNonEmptyEnvVar(params, "CI_WORKFLOW_NUMBER", strconv.Itoa(workflow.Number)) step := m.Step setNonEmptyEnvVar(params, "CI_STEP_NAME", step.Name) setNonEmptyEnvVar(params, "CI_STEP_NUMBER", strconv.Itoa(step.Number)) setNonEmptyEnvVar(params, "CI_STEP_URL", m.getPipelineWebURL(pipeline, step.Number)) // CI_STEP_STARTED will be set by agent commit := pipeline.Commit setNonEmptyEnvVar(params, "CI_COMMIT_SHA", commit.Sha) setNonEmptyEnvVar(params, "CI_COMMIT_REF", commit.Ref) setNonEmptyEnvVar(params, "CI_COMMIT_REFSPEC", commit.Refspec) setNonEmptyEnvVar(params, "CI_COMMIT_MESSAGE", commit.Message) setNonEmptyEnvVar(params, "CI_COMMIT_BRANCH", commit.Branch) setNonEmptyEnvVar(params, "CI_COMMIT_AUTHOR", commit.Author.Name) setNonEmptyEnvVar(params, "CI_COMMIT_AUTHOR_EMAIL", commit.Author.Email) if p, f := strings.CutPrefix(pipeline.Commit.Ref, "refs/tags/"); f { setNonEmptyEnvVar(params, "CI_COMMIT_TAG", p) } if pipeline.Event == EventRelease { setNonEmptyEnvVar(params, "CI_COMMIT_PRERELEASE", strconv.FormatBool(pipeline.Commit.IsPrerelease)) } if pipeline.Event.IsPull() { sourceBranch, targetBranch := getSourceTargetBranches(commit.Refspec) setNonEmptyEnvVar(params, "CI_COMMIT_SOURCE_BRANCH", sourceBranch) setNonEmptyEnvVar(params, "CI_COMMIT_TARGET_BRANCH", targetBranch) setNonEmptyEnvVar(params, "CI_COMMIT_PULL_REQUEST", pullRegexp.FindString(pipeline.Commit.Ref)) setNonEmptyEnvVar(params, "CI_COMMIT_PULL_REQUEST_LABELS", strings.Join(pipeline.Commit.PullRequestLabels, ",")) setNonEmptyEnvVar(params, "CI_COMMIT_PULL_REQUEST_MILESTONE", pipeline.Commit.PullRequestMilestone) } // Only export changed files if maxChangedFiles is not exceeded changedFiles := commit.ChangedFiles if len(changedFiles) == 0 { params["CI_PIPELINE_FILES"] = "[]" } else if len(changedFiles) <= maxChangedFiles { // we have to use json, as other separators like ;, or space are valid filename chars changedFiles, err := json.Marshal(changedFiles) if err != nil { log.Error().Err(err).Msg("marshal changed files") } params["CI_PIPELINE_FILES"] = string(changedFiles) } prevPipeline := m.Prev setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_NUMBER", strconv.FormatInt(prevPipeline.Number, 10)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_PARENT", strconv.FormatInt(prevPipeline.Parent, 10)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_EVENT", string(prevPipeline.Event)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_EVENT_REASON", strings.Join(prevPipeline.EventReason, ",")) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_URL", m.getPipelineWebURL(prevPipeline, 0)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_FORGE_URL", prevPipeline.ForgeURL) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_URL", prevPipeline.ForgeURL) // why commit url? setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_DEPLOY_TARGET", prevPipeline.DeployTo) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_DEPLOY_TASK", prevPipeline.DeployTask) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_STATUS", prevPipeline.Status) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_CREATED", strconv.FormatInt(prevPipeline.Created, 10)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_STARTED", strconv.FormatInt(prevPipeline.Started, 10)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_FINISHED", strconv.FormatInt(prevPipeline.Finished, 10)) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_AUTHOR", prevPipeline.Author) setNonEmptyEnvVar(params, "CI_PREV_PIPELINE_AVATAR", prevPipeline.Avatar) prevCommit := prevPipeline.Commit setNonEmptyEnvVar(params, "CI_PREV_COMMIT_SHA", prevCommit.Sha) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_REF", prevCommit.Ref) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_REFSPEC", prevCommit.Refspec) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_MESSAGE", prevCommit.Message) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_BRANCH", prevCommit.Branch) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_AUTHOR", prevCommit.Author.Name) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_AUTHOR_EMAIL", prevCommit.Author.Email) if prevPipeline.Event.IsPull() { prevSourceBranch, prevTargetBranch := getSourceTargetBranches(prevCommit.Refspec) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_SOURCE_BRANCH", prevSourceBranch) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_TARGET_BRANCH", prevTargetBranch) } // TODO Deprecated, remove in next major setNonEmptyEnvVar(params, "CI_COMMIT_AUTHOR_AVATAR", pipeline.Avatar) setNonEmptyEnvVar(params, "CI_PREV_COMMIT_AUTHOR_AVATAR", prevPipeline.Avatar) return params } func (m *Metadata) getPipelineWebURL(pipeline Pipeline, stepNumber int) string { if stepNumber == 0 { return fmt.Sprintf("%s/repos/%d/pipeline/%d", m.Sys.URL, m.Repo.ID, pipeline.Number) } return fmt.Sprintf("%s/repos/%d/pipeline/%d/%d", m.Sys.URL, m.Repo.ID, pipeline.Number, stepNumber) } func getSourceTargetBranches(refspec string) (string, string) { var ( sourceBranch string targetBranch string ) branchParts := strings.Split(refspec, ":") if len(branchParts) == 2 { //nolint:mnd sourceBranch = branchParts[0] targetBranch = branchParts[1] } return sourceBranch, targetBranch } func setNonEmptyEnvVar(env map[string]string, key, value string) { if len(value) > 0 { env[key] = value } else { log.Trace().Str("variable", key).Msg("env var is filtered as it's empty") } } ================================================ FILE: pipeline/frontend/metadata/environment_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata import ( "testing" "github.com/stretchr/testify/assert" ) func TestEnviron(t *testing.T) { m := Metadata{ Sys: System{Name: "wp"}, Curr: Pipeline{ Event: EventRelease, Commit: Commit{ Ref: "refs/tags/v1.2.3", IsPrerelease: true, }, }, Prev: Pipeline{ Event: EventPullMetadata, Commit: Commit{ Refspec: "branch-a:branch-b", }, }, } envs := m.Environ() assert.Equal(t, "wp", envs["CI"]) assert.Equal(t, "release", envs["CI_PIPELINE_EVENT"]) assert.Equal(t, "pull_request_metadata", envs["CI_PREV_PIPELINE_EVENT"]) assert.Equal(t, "true", envs["CI_COMMIT_PRERELEASE"]) assert.Equal(t, "branch-a", envs["CI_PREV_COMMIT_SOURCE_BRANCH"]) assert.Equal(t, "branch-b", envs["CI_PREV_COMMIT_TARGET_BRANCH"]) assert.Equal(t, "[]", envs["CI_PIPELINE_FILES"]) assert.Equal(t, "v1.2.3", envs["CI_COMMIT_TAG"]) m = Metadata{ Sys: System{Name: "wp"}, Curr: Pipeline{ Event: EventPull, Commit: Commit{ ChangedFiles: []string{"readme", "license"}, Refspec: "branch-a:branch-b", }, }, Prev: Pipeline{ Event: EventPull, Commit: Commit{ Refspec: "branch-a:branch-b", }, }, } envs = m.Environ() _, ok := envs["CI_COMMIT_TAG"] assert.False(t, ok) assert.Equal(t, `["readme","license"]`, envs["CI_PIPELINE_FILES"]) } ================================================ FILE: pipeline/frontend/metadata/substitution.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata import ( "fmt" "strings" "github.com/drone/envsubst" ) func EnvVarSubst(yaml string, environ map[string]string) (string, error) { return envsubst.Eval(yaml, func(name string) string { env := environ[name] if strings.Contains(env, "\n") { env = fmt.Sprintf("%q", env) } return env }) } ================================================ FILE: pipeline/frontend/metadata/substitution_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata import ( "testing" "github.com/stretchr/testify/assert" ) func TestEnvVarSubst(t *testing.T) { result, err := EnvVarSubst(`steps: step1: image: ${HELLO_IMAGE} command: echo ${NEWLINE}`, map[string]string{"HELLO_IMAGE": "hello-world", "NEWLINE": "some env\nwith newline"}) assert.NoError(t, err) assert.EqualValues(t, `steps: step1: image: hello-world command: echo "some env\nwith newline"`, result) } ================================================ FILE: pipeline/frontend/metadata/types.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metadata type ( // Metadata defines runtime m. Metadata struct { ID string `json:"id,omitempty"` Repo Repo `json:"repo,omitempty"` Curr Pipeline `json:"curr,omitempty"` Prev Pipeline `json:"prev,omitempty"` Workflow Workflow `json:"workflow,omitempty"` Step Step `json:"step,omitempty"` Sys System `json:"sys,omitempty"` Forge Forge `json:"forge,omitempty"` } // Repo defines runtime metadata for a repository. Repo struct { ID int64 `json:"id,omitempty"` Name string `json:"name,omitempty"` Owner string `json:"owner,omitempty"` RemoteID string `json:"remote_id,omitempty"` ForgeURL string `json:"forge_url,omitempty"` CloneURL string `json:"clone_url,omitempty"` CloneSSHURL string `json:"clone_url_ssh,omitempty"` Private bool `json:"private,omitempty"` Branch string `json:"default_branch,omitempty"` Trusted TrustedConfiguration `json:"trusted,omitempty"` } // Pipeline defines runtime metadata for a pipeline. Pipeline struct { Number int64 `json:"number,omitempty"` Created int64 `json:"created,omitempty"` Started int64 `json:"started,omitempty"` Finished int64 `json:"finished,omitempty"` Status string `json:"status,omitempty"` Event Event `json:"event,omitempty"` EventReason []string `json:"event_reason,omitempty"` ForgeURL string `json:"forge_url,omitempty"` DeployTo string `json:"target,omitempty"` DeployTask string `json:"task,omitempty"` Commit Commit `json:"commit"` Parent int64 `json:"parent,omitempty"` Cron string `json:"cron,omitempty"` Author string `json:"author,omitempty"` Avatar string `json:"avatar,omitempty"` } // Commit defines runtime metadata for a commit. Commit struct { Sha string `json:"sha,omitempty"` Ref string `json:"ref,omitempty"` Refspec string `json:"refspec,omitempty"` Branch string `json:"branch,omitempty"` Message string `json:"message,omitempty"` Author Author `json:"author"` ChangedFiles []string `json:"changed_files,omitempty"` PullRequestLabels []string `json:"labels,omitempty"` PullRequestMilestone string `json:"milestone,omitempty"` IsPrerelease bool `json:"is_prerelease,omitempty"` } // Author defines runtime metadata for a commit author. Author struct { Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` } // Workflow defines runtime metadata for a workflow. Workflow struct { Name string `json:"name,omitempty"` Number int `json:"number,omitempty"` Matrix map[string]string `json:"matrix,omitempty"` } // Step defines runtime metadata for a step. Step struct { Name string `json:"name,omitempty"` Number int `json:"number,omitempty"` } // System defines runtime metadata for a ci/cd system. System struct { Name string `json:"name,omitempty"` Host string `json:"host,omitempty"` URL string `json:"url,omitempty"` Platform string `json:"arch,omitempty"` Version string `json:"version,omitempty"` } // Forge defines runtime metadata about the forge that host the repo. Forge struct { Type string `json:"type,omitempty"` URL string `json:"url,omitempty"` } // ServerForge represent the needed func of a server forge to get its metadata. ServerForge interface { // Name returns the string name of this driver Name() string // URL returns the root url of a configured forge URL() string } TrustedConfiguration struct { Network bool `json:"network,omitempty"` Volumes bool `json:"volumes,omitempty"` Security bool `json:"security,omitempty"` } ) ================================================ FILE: pipeline/frontend/yaml/compiler/compiler.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "fmt" "maps" "path" "slices" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" yaml_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) const ( defaultCloneName = "clone" ) // Registry represents registry credentials. type Registry struct { Hostname string Username string Password string } type Secret struct { Name string Value string AllowedPlugins []string Events []metadata.Event } func (s *Secret) Available(event metadata.Event, container *yaml_types.Container) error { onlyAllowSecretForPlugins := len(s.AllowedPlugins) > 0 if onlyAllowSecretForPlugins && !container.IsPlugin() { return fmt.Errorf("secret %q is only allowed to be used by plugins (a filter has been set on the secret). Note: Image filters do not work for normal steps", s.Name) } if onlyAllowSecretForPlugins && !utils.MatchImageDynamic(container.Image, s.AllowedPlugins...) { return fmt.Errorf("secret %q is not allowed to be used with image %q by step %q", s.Name, container.Image, container.Name) } if !s.Match(event) { return fmt.Errorf("secret %q is not allowed to be used with pipeline event %q", s.Name, event) } return nil } // Match returns true if an image and event match the restricted list. // Note that EventPullClosed are treated as EventPull. func (s *Secret) Match(event metadata.Event) bool { // if there is no filter set secret matches all webhook events if len(s.Events) == 0 { return true } // treat all pull events the same way if event.IsPull() { event = metadata.EventPull } // one match is enough return slices.Contains(s.Events, event) } // Compiler compiles the yaml. type Compiler struct { local bool escalated []string prefix string volumes []string networks []string env map[string]string cloneEnv map[string]string workspaceBase string workspacePath string metadata metadata.Metadata registries []Registry secrets map[string]Secret defaultClonePlugin string trustedClonePlugins []string securityTrustedPipeline bool // TODO: remove with version 4.x forceIgnoreServiceFailure bool } // New creates a new Compiler with options. func New(opts ...Option) *Compiler { compiler := &Compiler{ env: map[string]string{}, cloneEnv: map[string]string{}, secrets: map[string]Secret{}, defaultClonePlugin: constant.DefaultClonePlugin, trustedClonePlugins: constant.TrustedClonePlugins, } for _, opt := range opts { opt(compiler) } return compiler } // Compile compiles the YAML configuration to the pipeline intermediate // representation configuration format. func (c *Compiler) Compile(conf *yaml_types.Workflow) (*backend_types.Config, error) { config := new(backend_types.Config) if match, err := conf.When.Match(c.metadata, true, c.env); !match && err == nil { // This pipeline does not match the configured filter so return an empty config and stop further compilation. // An empty pipeline will just be skipped completely. return config, nil } else if err != nil { return nil, err } // create a default volume config.Volume = fmt.Sprintf("%s_default", c.prefix) // create a default network config.Network = fmt.Sprintf("%s_default", c.prefix) // create secrets for mask for _, sec := range c.secrets { config.Secrets = append(config.Secrets, &backend_types.Secret{ Name: sec.Name, Value: sec.Value, }) } // overrides the default workspace paths when specified // in the YAML file. if len(conf.Workspace.Base) != 0 { c.workspaceBase = path.Clean(conf.Workspace.Base) } if len(conf.Workspace.Path) != 0 { c.workspacePath = path.Clean(conf.Workspace.Path) } // add default clone step if !c.local && len(conf.Clone.ContainerList) == 0 && !conf.SkipClone && len(c.defaultClonePlugin) != 0 { cloneSettings := map[string]any{"depth": "0"} if c.metadata.Curr.Event == metadata.EventTag { cloneSettings["tags"] = "true" } container := &yaml_types.Container{ Name: defaultCloneName, Image: c.defaultClonePlugin, Settings: cloneSettings, Environment: make(map[string]any), } for k, v := range c.cloneEnv { container.Environment[k] = v } step, err := c.createProcess(container, conf, backend_types.StepTypeClone) if err != nil { return nil, err } stage := new(backend_types.Stage) stage.Steps = append(stage.Steps, step) config.Stages = append(config.Stages, stage) } else if !c.local && !conf.SkipClone { for _, container := range conf.Clone.ContainerList { if match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil { continue } else if err != nil { return nil, err } stage := new(backend_types.Stage) step, err := c.createProcess(container, conf, backend_types.StepTypeClone) if err != nil { return nil, err } // only inject netrc if it's a trusted repo or a trusted plugin if c.securityTrustedPipeline || (container.IsPlugin() && container.IsTrustedCloneImage(c.trustedClonePlugins)) { maps.Copy(step.Environment, c.cloneEnv) } stage.Steps = append(stage.Steps, step) config.Stages = append(config.Stages, stage) } } // add services steps if len(conf.Services.ContainerList) != 0 { stage := new(backend_types.Stage) for _, container := range conf.Services.ContainerList { if match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil { continue } else if err != nil { return nil, err } step, err := c.createProcess(container, conf, backend_types.StepTypeService) if err != nil { return nil, err } stage.Steps = append(stage.Steps, step) } config.Stages = append(config.Stages, stage) } // add pipeline steps steps := make([]*dagCompilerStep, 0, len(conf.Steps.ContainerList)) for pos, container := range conf.Steps.ContainerList { // Skip if local and should not run local if c.local && !container.When.IsLocal() { continue } if match, err := container.When.Match(c.metadata, false, c.env); !match && err == nil { continue } else if err != nil { return nil, err } stepType := backend_types.StepTypeCommands if container.IsPlugin() { stepType = backend_types.StepTypePlugin } step, err := c.createProcess(container, conf, stepType) if err != nil { return nil, err } // only inject netrc if it's a trusted repo or a trusted plugin if c.securityTrustedPipeline || (container.IsPlugin() && container.IsTrustedCloneImage(c.trustedClonePlugins)) { maps.Copy(step.Environment, c.cloneEnv) } steps = append(steps, &dagCompilerStep{ step: step, position: pos, name: container.Name, dependsOn: container.DependsOn, }) } // generate stages out of steps stepStages, err := newDAGCompiler(steps).compile() if err != nil { return nil, err } config.Stages = append(config.Stages, stepStages...) return config, nil } ================================================ FILE: pipeline/frontend/yaml/compiler/compiler_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "testing" "github.com/stretchr/testify/assert" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" yaml_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types" yaml_base_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) func TestSecretAvailable(t *testing.T) { secret := Secret{ AllowedPlugins: []string{}, Events: []metadata.Event{"push"}, } assert.NoError(t, secret.Available("push", &yaml_types.Container{ Image: "golang", Commands: yaml_base_types.StringOrSlice{"echo 'this is not a plugin'"}, })) // secret only available for "golang" plugin secret = Secret{ Name: "foo", AllowedPlugins: []string{"golang"}, Events: []metadata.Event{"push"}, } assert.NoError(t, secret.Available("push", &yaml_types.Container{ Name: "step", Image: "golang", Commands: yaml_base_types.StringOrSlice{}, })) assert.ErrorContains(t, secret.Available("push", &yaml_types.Container{ Image: "golang", Commands: yaml_base_types.StringOrSlice{"echo 'this is not a plugin'"}, }), "is only allowed to be used by plugins (a filter has been set on the secret). Note: Image filters do not work for normal steps") assert.ErrorContains(t, secret.Available("push", &yaml_types.Container{ Image: "not-golang", Commands: yaml_base_types.StringOrSlice{}, }), "not allowed to be used with image ") assert.ErrorContains(t, secret.Available("pull_request", &yaml_types.Container{ Image: "golang", }), "not allowed to be used with pipeline event ") } func TestCompilerCompile(t *testing.T) { repoURL := "https://github.com/octocat/hello-world" compiler := New( WithMetadata(metadata.Metadata{ Repo: metadata.Repo{ Owner: "octacat", Name: "hello-world", Private: true, ForgeURL: repoURL, CloneURL: "https://github.com/octocat/hello-world.git", }, }), WithEnviron(map[string]string{ "VERBOSE": "true", "COLORED": "true", }), WithPrefix("test"), // we use "/test" as custom workspace base to ensure the enforcement of the pluginWorkspaceBase is applied WithWorkspaceFromURL("/test", repoURL), ) defaultNetwork := "test_default" defaultVolume := "test_default" defaultCloneStage := &backend_types.Stage{ Steps: []*backend_types.Step{{ Name: "clone", Type: backend_types.StepTypeClone, Image: constant.DefaultClonePlugin, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/woodpecker"}, WorkingDir: "/woodpecker/src/github.com/octocat/hello-world", WorkspaceBase: "/woodpecker", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"clone"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, } tests := []struct { name string fronConf *yaml_types.Workflow backConf *backend_types.Config expectedErr string }{ { name: "empty workflow, no clone", fronConf: &yaml_types.Workflow{SkipClone: true}, backConf: &backend_types.Config{ Network: defaultNetwork, Volume: defaultVolume, }, }, { name: "empty workflow, default clone", fronConf: &yaml_types.Workflow{}, backConf: &backend_types.Config{ Network: defaultNetwork, Volume: defaultVolume, Stages: []*backend_types.Stage{defaultCloneStage}, }, }, { name: "workflow with one dummy step", fronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{ Name: "dummy", Image: "dummy_img", }}}}, backConf: &backend_types.Config{ Network: defaultNetwork, Volume: defaultVolume, Stages: []*backend_types.Stage{defaultCloneStage, { Steps: []*backend_types.Step{{ Name: "dummy", Type: backend_types.StepTypePlugin, Image: "dummy_img", OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/woodpecker"}, WorkingDir: "/woodpecker/src/github.com/octocat/hello-world", WorkspaceBase: "/woodpecker", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"dummy"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, }}, }, }, { name: "workflow with three steps", fronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{ Name: "echo env", Image: "bash", Commands: []string{"env"}, }, { Name: "parallel echo 1", Image: "bash", Commands: []string{"echo 1"}, }, { Name: "parallel echo 2", Image: "bash", Commands: []string{"echo 2"}, }}}}, backConf: &backend_types.Config{ Network: defaultNetwork, Volume: defaultVolume, Stages: []*backend_types.Stage{ defaultCloneStage, { Steps: []*backend_types.Step{{ Name: "echo env", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"env"}, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/test"}, WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"echo env"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, }, { Steps: []*backend_types.Step{{ Name: "parallel echo 1", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"echo 1"}, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/test"}, WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"parallel echo 1"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, }, { Steps: []*backend_types.Step{{ Name: "parallel echo 2", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"echo 2"}, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/test"}, WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"parallel echo 2"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, }, }, }, }, { name: "workflow with three steps and depends_on", fronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{ Name: "echo env", Image: "bash", Commands: []string{"env"}, }, { Name: "echo 1", Image: "bash", Commands: []string{"echo 1"}, DependsOn: []string{"echo env", "echo 2"}, }, { Name: "echo 2", Image: "bash", Commands: []string{"echo 2"}, }}}}, backConf: &backend_types.Config{ Network: defaultNetwork, Volume: defaultVolume, Stages: []*backend_types.Stage{defaultCloneStage, { Steps: []*backend_types.Step{{ Name: "echo env", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"env"}, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/test"}, WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"echo env"}}}, ExtraHosts: []backend_types.HostAlias{}, }, { Name: "echo 2", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"echo 2"}, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/test"}, WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"echo 2"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, }, { Steps: []*backend_types.Step{{ Name: "echo 1", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"echo 1"}, OnSuccess: true, Failure: "fail", Volumes: []string{defaultVolume + ":/test"}, WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"echo 1"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, }}, }, }, { name: "workflow with missing secret", fronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{ Name: "step", Image: "bash", Commands: []string{"env"}, Environment: map[string]any{ "MISSING": map[string]any{"from_secret": "missing"}, }, }}}}, backConf: nil, expectedErr: "secret \"missing\" not found", }, { name: "workflow with broken step dependency", fronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{ Name: "dummy", Image: "dummy_img", DependsOn: []string{"not exist"}, }}}}, backConf: nil, expectedErr: "step 'dummy' depends on unknown step 'not exist'", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { backConf, err := compiler.Compile(test.fronConf) if test.expectedErr != "" { assert.Error(t, err) assert.Equal(t, test.expectedErr, err.Error()) } else { // we ignore uuids in steps and only check if global env got set ... for _, st := range backConf.Stages { for _, s := range st.Steps { s.UUID = "" assert.Truef(t, s.Environment["VERBOSE"] == "true", "expected to get value of global set environment") assert.Truef(t, len(s.Environment) > 10, "expected to have a lot of built-in variables") s.Environment = nil s.SecretMapping = nil } } // check if we get an expected backend config based on a frontend config assert.EqualValues(t, *test.backConf, *backConf) } }) } } func TestCompilerCompileWithFromSecret(t *testing.T) { repoURL := "https://github.com/octocat/hello-world" compiler := New( WithMetadata(metadata.Metadata{ Repo: metadata.Repo{ Owner: "octacat", Name: "hello-world", Private: true, ForgeURL: repoURL, CloneURL: "https://github.com/octocat/hello-world.git", }, }), WithEnviron(map[string]string{ "VERBOSE": "true", "COLORED": "true", }), WithSecret(Secret{ Name: "secret_name", Value: "VERY_SECRET", }), WithPrefix("test"), // we use "/test" as custom workspace base to ensure the enforcement of the pluginWorkspaceBase is applied WithWorkspaceFromURL("/test", repoURL), ) defaultNetwork := "test_default" defaultVolume := "test_default" defaultCloneStage := &backend_types.Stage{ Steps: []*backend_types.Step{{ Name: "clone", Type: backend_types.StepTypeClone, Image: constant.DefaultClonePlugin, OnSuccess: true, Failure: "fail", WorkingDir: "/woodpecker/src/github.com/octocat/hello-world", WorkspaceBase: "/woodpecker", Volumes: []string{defaultVolume + ":/woodpecker"}, Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"clone"}}}, ExtraHosts: []backend_types.HostAlias{}, }}, } tests := []struct { name string fronConf *yaml_types.Workflow backConf *backend_types.Config expectedErr string }{ { name: "workflow with missing secret", fronConf: &yaml_types.Workflow{Steps: yaml_types.ContainerList{ContainerList: []*yaml_types.Container{{ Name: "step", Image: "bash", Commands: []string{"env"}, Environment: map[string]any{ "SECRET": map[string]any{"from_secret": "secret_name"}, }, }}}}, backConf: &backend_types.Config{ Stages: []*backend_types.Stage{defaultCloneStage, { Steps: []*backend_types.Step{{ Name: "step", Type: backend_types.StepTypeCommands, Image: "bash", Commands: []string{"env"}, OnSuccess: true, Failure: "fail", WorkingDir: "/test/src/github.com/octocat/hello-world", WorkspaceBase: "/test", Volumes: []string{defaultVolume + ":/test"}, Networks: []backend_types.Conn{{Name: "test_default", Aliases: []string{"step"}}}, ExtraHosts: []backend_types.HostAlias{}, SecretMapping: map[string]string{ "SECRET": "VERY_SECRET", }, }}, }}, Volume: defaultVolume, Network: defaultNetwork, Secrets: []*backend_types.Secret{{ Name: "secret_name", Value: "VERY_SECRET", }}, }, expectedErr: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { backConf, err := compiler.Compile(test.fronConf) if test.expectedErr != "" { assert.Error(t, err) assert.Equal(t, test.expectedErr, err.Error()) } else { // we ignore uuids in steps and only check if global env got set ... for _, st := range backConf.Stages { for _, s := range st.Steps { s.UUID = "" assert.Truef(t, s.Environment["VERBOSE"] == "true", "expected to get value of global set environment") assert.Truef(t, len(s.Environment) > 10, "expected to have a lot of built-in variables") s.Environment = nil if len(s.SecretMapping) == 0 { s.SecretMapping = nil } } } // check if we get an expected backend config based on a frontend config assert.EqualValues(t, *test.backConf, *backConf) } }) } } func TestSecretMatch(t *testing.T) { tcl := []*struct { name string secret Secret event metadata.Event match bool }{ { name: "should match event", secret: Secret{Events: []metadata.Event{"pull_request"}}, event: "pull_request", match: true, }, { name: "should not match event", secret: Secret{Events: []metadata.Event{"pull_request"}}, event: "push", match: false, }, { name: "should match when no event filters defined", secret: Secret{}, event: "pull_request", match: true, }, { name: "pull close should match pull", secret: Secret{Events: []metadata.Event{"pull_request"}}, event: "pull_request_closed", match: true, }, { name: "pull metadata change should match pull", secret: Secret{Events: []metadata.Event{"pull_request"}}, event: "pull_request_metadata", match: true, }, } for _, tc := range tcl { t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.match, tc.secret.Match(tc.event)) }) } } func TestCompilerCompilePrivileged(t *testing.T) { compiler := New( WithEscalated("test/image"), ) fronConf := &yaml_types.Workflow{ SkipClone: true, Steps: yaml_types.ContainerList{ ContainerList: []*yaml_types.Container{ { Name: "privileged-plugin", Image: "test/image", DependsOn: []string{}, // no dependencies => enable dag mode & all steps are executed in parallel }, { Name: "no-plugin", Image: "test/image", Commands: []string{"echo 'i am not a plugin anymore'"}, }, { Name: "not-privileged-image", Image: "some/other-image", }, }, }, } backConf, err := compiler.Compile(fronConf) assert.NoError(t, err) assert.Len(t, backConf.Stages, 1) assert.Len(t, backConf.Stages[0].Steps, 3) assert.True(t, backConf.Stages[0].Steps[0].Privileged) assert.False(t, backConf.Stages[0].Steps[1].Privileged) assert.False(t, backConf.Stages[0].Steps[2].Privileged) } ================================================ FILE: pipeline/frontend/yaml/compiler/convert.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "fmt" "maps" "path" "strconv" "strings" "github.com/oklog/ulid/v2" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler/settings" yaml_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils" ) const ( // The pluginWorkspaceBase should not be changed, only if you are sure what you do. pluginWorkspaceBase = "/woodpecker" // DefaultWorkspaceBase is set if not altered by the user. DefaultWorkspaceBase = pluginWorkspaceBase ) func (c *Compiler) createProcess(container *yaml_types.Container, workflow *yaml_types.Workflow, stepType backend_types.StepType) (*backend_types.Step, error) { var ( uuid = ulid.Make() detached bool workingDir string privileged = container.Privileged networkMode = container.NetworkMode ) workspaceBase := c.workspaceBase if container.IsPlugin() { // plugins have a predefined workspace base to not tamper with entrypoint executables workspaceBase = pluginWorkspaceBase } workspaceVolume := fmt.Sprintf("%s_default:%s", c.prefix, workspaceBase) networks := []backend_types.Conn{ { Name: fmt.Sprintf("%s_default", c.prefix), Aliases: []string{container.Name}, }, } for _, network := range c.networks { networks = append(networks, backend_types.Conn{ Name: network, }) } extraHosts := make([]backend_types.HostAlias, len(container.ExtraHosts)) for i, extraHost := range container.ExtraHosts { name, ip, ok := strings.Cut(extraHost, ":") if !ok { return nil, &ErrExtraHostFormat{host: extraHost} } extraHosts[i].Name = name extraHosts[i].IP = ip } var volumes []string if !c.local { volumes = append(volumes, workspaceVolume) } volumes = append(volumes, c.volumes...) for _, volume := range container.Volumes.Volumes { volumes = append(volumes, volume.String()) } // append default environment variables environment := map[string]string{} maps.Copy(environment, c.env) environment["CI_WORKSPACE"] = path.Join(workspaceBase, c.workspacePath) if stepType == backend_types.StepTypeService || container.Detached { detached = true } workingDir = c.stepWorkingDir(container) getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := c.secrets[name] if !ok { return "", fmt.Errorf("secret %q not found", name) } event := c.metadata.Curr.Event err := secret.Available(event, container) if err != nil { return "", err } return secret.Value, nil } secretMapping := map[string]string{} if err := settings.ParamsToEnv(container.Settings, environment, "PLUGIN_", true, getSecretValue, secretMapping); err != nil { return nil, err } if err := settings.ParamsToEnv(container.Environment, environment, "", false, getSecretValue, secretMapping); err != nil { return nil, err } if utils.MatchImageDynamic(container.Image, c.escalated...) && container.IsPlugin() { privileged = true } authConfig := backend_types.Auth{} for _, registry := range c.registries { if utils.MatchHostname(container.Image, registry.Hostname) { authConfig.Username = registry.Username authConfig.Password = registry.Password break } } var ports []backend_types.Port for _, portDef := range container.Ports { port, err := convertPort(portDef) if err != nil { return nil, err } ports = append(ports, port) } // at least one constraint contain status success, or all constraints have no status set onSuccess := container.When.IncludesStatusSuccess(c.metadata, false, c.env) // at least one constraint must include the status failure. onFailure := container.When.IncludesStatusFailure(c.metadata, false, c.env) failure := container.Failure if container.Failure == "" { failure = string(metadata.FailureFail) } // TODO: remove with version 4.x if c.forceIgnoreServiceFailure && detached { failure = string(metadata.FailureIgnore) } return &backend_types.Step{ Name: container.Name, UUID: uuid.String(), Type: stepType, Image: container.Image, Pull: container.Pull, Detached: detached, Privileged: privileged, WorkingDir: workingDir, WorkspaceBase: workspaceBase, Environment: environment, SecretMapping: secretMapping, Commands: container.Commands, Entrypoint: container.Entrypoint, ExtraHosts: extraHosts, Volumes: volumes, Tmpfs: container.Tmpfs, Devices: container.Devices, Networks: networks, DNS: container.DNS, DNSSearch: container.DNSSearch, AuthConfig: authConfig, OnSuccess: onSuccess, OnFailure: onFailure, Failure: failure, NetworkMode: networkMode, Ports: ports, BackendOptions: container.BackendOptions, WorkflowLabels: workflow.Labels, }, nil } func (c *Compiler) stepWorkingDir(container *yaml_types.Container) string { if path.IsAbs(container.Directory) { return container.Directory } base := c.workspaceBase if container.IsPlugin() { base = pluginWorkspaceBase } return path.Join(base, c.workspacePath, container.Directory) } func convertPort(portDef string) (backend_types.Port, error) { var err error var port backend_types.Port number, protocol, _ := strings.Cut(portDef, "/") port.Protocol = protocol portNumber, err := strconv.ParseUint(number, 10, 16) if err != nil { return port, err } port.Number = uint16(portNumber) return port, nil } ================================================ FILE: pipeline/frontend/yaml/compiler/convert_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "testing" "github.com/stretchr/testify/assert" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestConvertPortNumber(t *testing.T) { portDef := "1234" actualPort, err := convertPort(portDef) assert.NoError(t, err) assert.Equal(t, backend_types.Port{ Number: 1234, Protocol: "", }, actualPort) } func TestConvertPortUdp(t *testing.T) { portDef := "1234/udp" actualPort, err := convertPort(portDef) assert.NoError(t, err) assert.Equal(t, backend_types.Port{ Number: 1234, Protocol: "udp", }, actualPort) } func TestConvertPortWrongOrder(t *testing.T) { portDef := "tcp/1234" _, err := convertPort(portDef) assert.Error(t, err) } func TestConvertPortWrongDelimiter(t *testing.T) { portDef := "1234|udp" _, err := convertPort(portDef) assert.Error(t, err) } func TestConvertPortWrong(t *testing.T) { portDef := "http" _, err := convertPort(portDef) assert.Error(t, err) } ================================================ FILE: pipeline/frontend/yaml/compiler/dag.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "sort" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) type dagCompilerStep struct { step *backend_types.Step position int name string dependsOn []string } type dagCompiler struct { steps []*dagCompilerStep } func newDAGCompiler(steps []*dagCompilerStep) dagCompiler { return dagCompiler{ steps: steps, } } func (c dagCompiler) isDAG() bool { for _, v := range c.steps { if v.dependsOn != nil { return true } } return false } func (c dagCompiler) compile() ([]*backend_types.Stage, error) { if c.isDAG() { return c.compileByDependsOn() } return c.compileSequence() } func (c dagCompiler) compileSequence() ([]*backend_types.Stage, error) { stages := make([]*backend_types.Stage, 0, len(c.steps)) for _, s := range c.steps { stages = append(stages, &backend_types.Stage{ Steps: []*backend_types.Step{s.step}, }) } return stages, nil } func (c dagCompiler) compileByDependsOn() ([]*backend_types.Stage, error) { stepMap := make(map[string]*dagCompilerStep, len(c.steps)) for _, s := range c.steps { stepMap[s.name] = s } return convertDAGToStages(stepMap) } func dfsVisit(steps map[string]*dagCompilerStep, name string, visited map[string]struct{}, path []string) error { if _, ok := visited[name]; ok { return &ErrStepDependencyCycle{path: path} } visited[name] = struct{}{} path = append(path, name) for _, dep := range steps[name].dependsOn { if err := dfsVisit(steps, dep, visited, path); err != nil { return err } } delete(visited, name) return nil } func convertDAGToStages(steps map[string]*dagCompilerStep) ([]*backend_types.Stage, error) { addedSteps := make(map[string]struct{}) stages := make([]*backend_types.Stage, 0) for name, step := range steps { // check if all depends_on are valid for _, dep := range step.dependsOn { if _, ok := steps[dep]; !ok { return nil, &ErrStepMissingDependency{name: name, dep: dep} } } // check if there are cycles visited := make(map[string]struct{}) if err := dfsVisit(steps, name, visited, []string{}); err != nil { return nil, err } } for len(steps) > 0 { addedNodesThisLevel := make(map[string]struct{}) stage := new(backend_types.Stage) var stepsToAdd []*dagCompilerStep for name, step := range steps { if allDependenciesSatisfied(step, addedSteps) { stepsToAdd = append(stepsToAdd, step) addedNodesThisLevel[name] = struct{}{} delete(steps, name) } } // as steps are from a map that has no deterministic order, // we sort the steps by original config position to make the order similar between pipelines sort.Slice(stepsToAdd, func(i, j int) bool { return stepsToAdd[i].position < stepsToAdd[j].position }) for i := range stepsToAdd { stage.Steps = append(stage.Steps, stepsToAdd[i].step) } for name := range addedNodesThisLevel { addedSteps[name] = struct{}{} } stages = append(stages, stage) } return stages, nil } func allDependenciesSatisfied(step *dagCompilerStep, addedSteps map[string]struct{}) bool { for _, childName := range step.dependsOn { _, ok := addedSteps[childName] if !ok { return false } } return true } ================================================ FILE: pipeline/frontend/yaml/compiler/dag_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "testing" "github.com/stretchr/testify/assert" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) func TestConvertDAGToStages(t *testing.T) { steps := map[string]*dagCompilerStep{ "step1": { step: &backend_types.Step{}, dependsOn: []string{"step3"}, }, "step2": { step: &backend_types.Step{}, dependsOn: []string{"step1"}, }, "step3": { step: &backend_types.Step{}, dependsOn: []string{"step2"}, }, } _, err := convertDAGToStages(steps) assert.ErrorIs(t, err, &ErrStepDependencyCycle{}) steps = map[string]*dagCompilerStep{ "step1": { step: &backend_types.Step{}, dependsOn: []string{"step2"}, }, "step2": { step: &backend_types.Step{}, }, } _, err = convertDAGToStages(steps) assert.NoError(t, err) steps = map[string]*dagCompilerStep{ "a": { step: &backend_types.Step{}, }, "b": { step: &backend_types.Step{}, dependsOn: []string{"a"}, }, "c": { step: &backend_types.Step{}, dependsOn: []string{"a"}, }, "d": { step: &backend_types.Step{}, dependsOn: []string{"b", "c"}, }, } _, err = convertDAGToStages(steps) assert.NoError(t, err) steps = map[string]*dagCompilerStep{ "step1": { step: &backend_types.Step{}, dependsOn: []string{"not-existing-step"}, }, } _, err = convertDAGToStages(steps) assert.ErrorIs(t, err, &ErrStepMissingDependency{}) steps = map[string]*dagCompilerStep{ "echo env": { position: 0, name: "echo env", step: &backend_types.Step{ UUID: "01HJDPEW6R7J0JBE3F1T7Q0TYX", Type: "commands", Name: "echo env", Image: "bash", }, }, "echo 1": { position: 1, name: "echo 1", dependsOn: []string{"echo env", "echo 2"}, step: &backend_types.Step{ UUID: "01HJDPF770QGRZER8RF79XVS4M", Type: "commands", Name: "echo 1", Image: "bash", }, }, "echo 2": { position: 2, name: "echo 2", step: &backend_types.Step{ UUID: "01HJDPFF5RMEYZW0YTGR1Y1ZR0", Type: "commands", Name: "echo 2", Image: "bash", }, }, } stages, err := convertDAGToStages(steps) assert.NoError(t, err) assert.EqualValues(t, []*backend_types.Stage{{ Steps: []*backend_types.Step{{ UUID: "01HJDPEW6R7J0JBE3F1T7Q0TYX", Type: "commands", Name: "echo env", Image: "bash", }, { UUID: "01HJDPFF5RMEYZW0YTGR1Y1ZR0", Type: "commands", Name: "echo 2", Image: "bash", }}, }, { Steps: []*backend_types.Step{{ UUID: "01HJDPF770QGRZER8RF79XVS4M", Type: "commands", Name: "echo 1", Image: "bash", }}, }}, stages) } func TestIsDag(t *testing.T) { steps := []*dagCompilerStep{ { step: &backend_types.Step{}, }, } c := newDAGCompiler(steps) assert.False(t, c.isDAG()) steps = []*dagCompilerStep{ { step: &backend_types.Step{}, dependsOn: []string{}, }, } c = newDAGCompiler(steps) assert.True(t, c.isDAG()) } ================================================ FILE: pipeline/frontend/yaml/compiler/errors.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import "fmt" type ErrExtraHostFormat struct { host string } func (err *ErrExtraHostFormat) Error() string { return fmt.Sprintf("extra host %s is in wrong format", err.host) } func (*ErrExtraHostFormat) Is(target error) bool { _, ok := target.(*ErrExtraHostFormat) return ok } type ErrStepMissingDependency struct { name, dep string } func (err *ErrStepMissingDependency) Error() string { return fmt.Sprintf("step '%s' depends on unknown step '%s'", err.name, err.dep) } func (*ErrStepMissingDependency) Is(target error) bool { _, ok := target.(*ErrStepMissingDependency) return ok } type ErrStepDependencyCycle struct { path []string } func (err *ErrStepDependencyCycle) Error() string { return fmt.Sprintf("cycle detected: %v", err.path) } func (*ErrStepDependencyCycle) Is(target error) bool { _, ok := target.(*ErrStepDependencyCycle) return ok } ================================================ FILE: pipeline/frontend/yaml/compiler/option.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "maps" "net/url" "path" "strings" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" ) // Option configures a compiler option. type Option func(*Compiler) func noopOption() Option { return func(*Compiler) {} } // WithOption configures the compiler with the given option if // boolean b evaluates to true. func WithOption(option Option, b bool) Option { switch { case b: return option default: return func(_ *Compiler) {} } } // WithVolumes configures the compiler with default volumes that // are mounted to each container in the pipeline. func WithVolumes(volumes ...string) Option { return func(compiler *Compiler) { compiler.volumes = volumes } } // WithRegistry configures the compiler with registry credentials // that should be used to download images. func WithRegistry(registries ...Registry) Option { return func(compiler *Compiler) { compiler.registries = registries } } // WithSecret configures the compiler with external secrets // to be injected into the container at runtime. func WithSecret(secrets ...Secret) Option { return func(compiler *Compiler) { for _, secret := range secrets { compiler.secrets[strings.ToLower(secret.Name)] = secret } } } // WithMetadata configures the compiler with the repository, pipeline // and system metadata. The metadata is used to remove steps from // the compiled pipeline configuration that should be skipped. The // metadata is also added to each container as environment variables. func WithMetadata(metadata metadata.Metadata) Option { return func(compiler *Compiler) { compiler.metadata = metadata maps.Copy(compiler.env, metadata.Environ()) } } // WithNetrc configures the compiler with netrc authentication // credentials added by default to every container in the pipeline. func WithNetrc(username, password, machine string) Option { return func(compiler *Compiler) { compiler.cloneEnv["CI_NETRC_USERNAME"] = username compiler.cloneEnv["CI_NETRC_PASSWORD"] = password compiler.cloneEnv["CI_NETRC_MACHINE"] = machine } } // WithWorkspace configures the compiler with the workspace base // and path. The workspace base is a volume created at runtime and // mounted into all containers in the pipeline. The base and path // are joined to provide the working directory for all pipeline and // plugin steps in the pipeline. func WithWorkspace(base, path string) Option { return func(compiler *Compiler) { compiler.workspaceBase = base compiler.workspacePath = path } } // WithWorkspaceFromURL configures the compiler with the workspace // base and path based on the repository url. func WithWorkspaceFromURL(base, u string) Option { srcPath := "src" parsed, err := url.Parse(u) if err == nil { srcPath = path.Join(srcPath, parsed.Hostname(), parsed.Path) } return WithWorkspace(base, srcPath) } // WithEscalated configures the compiler to automatically execute // images as privileged containers if the match the given list. func WithEscalated(images ...string) Option { return func(compiler *Compiler) { compiler.escalated = images } } // WithPrefix configures the compiler with the prefix. The prefix is // used to prefix container, volume and network names to avoid // collision at runtime. func WithPrefix(prefix string) Option { return func(compiler *Compiler) { compiler.prefix = prefix } } // WithLocal configures the compiler with the local flag. The local // flag indicates the pipeline execution is running in a local development // environment with a mounted local working directory. func WithLocal(local bool) Option { return func(compiler *Compiler) { compiler.local = local } } // WithEnviron configures the compiler with environment variables // added by default to every container in the pipeline. func WithEnviron(env map[string]string) Option { return func(compiler *Compiler) { maps.Copy(compiler.env, env) } } // WithNetworks configures the compiler with additional networks // to be connected to pipeline containers. func WithNetworks(networks ...string) Option { return func(compiler *Compiler) { compiler.networks = networks } } func WithDefaultClonePlugin(cloneImage string) Option { return func(compiler *Compiler) { compiler.defaultClonePlugin = cloneImage } } func WithTrustedClonePlugins(images []string) Option { return func(compiler *Compiler) { compiler.trustedClonePlugins = images } } // WithTrustedSecurity configures the compiler with the trusted repo option. func WithTrustedSecurity(trusted bool) Option { return func(compiler *Compiler) { compiler.securityTrustedPipeline = trusted } } type ProxyOptions struct { NoProxy string HTTPProxy string HTTPSProxy string } // WithProxy configures the compiler with HTTP_PROXY, HTTPS_PROXY, // and NO_PROXY environment variables added by default to every // container in the pipeline. func WithProxy(opt ProxyOptions) Option { if opt.HTTPProxy == "" && opt.HTTPSProxy == "" && opt.NoProxy == "" { return noopOption() } return WithEnviron( map[string]string{ "no_proxy": opt.NoProxy, "NO_PROXY": opt.NoProxy, "http_proxy": opt.HTTPProxy, "HTTP_PROXY": opt.HTTPProxy, "HTTPS_PROXY": opt.HTTPSProxy, "https_proxy": opt.HTTPSProxy, }, ) } // TODO: remove with version 4.x func WithForceIgnoreServiceFailure() Option { return func(c *Compiler) { c.forceIgnoreServiceFailure = true } } ================================================ FILE: pipeline/frontend/yaml/compiler/option_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compiler import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) func TestWithWorkspace(t *testing.T) { compiler := New( WithWorkspace( "/pipeline", "src/github.com/octocat/hello-world", ), ) assert.Equal(t, "/pipeline", compiler.workspaceBase) assert.Equal(t, "src/github.com/octocat/hello-world", compiler.workspacePath) } func TestWithEscalated(t *testing.T) { compiler := New( WithEscalated( "docker", "docker-dev", ), ) assert.Equal(t, "docker", compiler.escalated[0]) assert.Equal(t, "docker-dev", compiler.escalated[1]) } func TestWithVolumes(t *testing.T) { compiler := New( WithVolumes( "/tmp:/tmp", "/foo:/foo", ), ) assert.Equal(t, "/tmp:/tmp", compiler.volumes[0]) assert.Equal(t, "/foo:/foo", compiler.volumes[1]) } func TestWithNetworks(t *testing.T) { compiler := New( WithNetworks( "overlay_1", "overlay_bar", ), ) assert.Equal(t, "overlay_1", compiler.networks[0]) assert.Equal(t, "overlay_bar", compiler.networks[1]) } func TestWithPrefix(t *testing.T) { assert.Equal(t, "someprefix_", New(WithPrefix("someprefix_")).prefix) } func TestWithMetadata(t *testing.T) { metadata := metadata.Metadata{ Repo: metadata.Repo{ Owner: "octacat", Name: "hello-world", Private: true, ForgeURL: "https://github.com/octocat/hello-world", CloneURL: "https://github.com/octocat/hello-world.git", }, } compiler := New( WithMetadata(metadata), ) assert.Equal(t, metadata, compiler.metadata) assert.Equal(t, metadata.Repo.Name, compiler.env["CI_REPO_NAME"]) assert.Equal(t, metadata.Repo.ForgeURL, compiler.env["CI_REPO_URL"]) assert.Equal(t, metadata.Repo.CloneURL, compiler.env["CI_REPO_CLONE_URL"]) } func TestWithLocal(t *testing.T) { assert.True(t, New(WithLocal(true)).local) assert.False(t, New(WithLocal(false)).local) } func TestWithNetrc(t *testing.T) { compiler := New( WithNetrc( "octocat", "password", "github.com", ), ) assert.Equal(t, "octocat", compiler.cloneEnv["CI_NETRC_USERNAME"]) assert.Equal(t, "password", compiler.cloneEnv["CI_NETRC_PASSWORD"]) assert.Equal(t, "github.com", compiler.cloneEnv["CI_NETRC_MACHINE"]) } func TestWithProxy(t *testing.T) { // alter the default values noProxy := "example.com" httpProxy := "bar.com" httpsProxy := "baz.com" testdata := map[string]string{ "no_proxy": noProxy, "NO_PROXY": noProxy, "http_proxy": httpProxy, "HTTP_PROXY": httpProxy, "https_proxy": httpsProxy, "HTTPS_PROXY": httpsProxy, } compiler := New( WithProxy(ProxyOptions{ NoProxy: noProxy, HTTPProxy: httpProxy, HTTPSProxy: httpsProxy, }), ) for key, value := range testdata { assert.Equal(t, value, compiler.env[key]) } } func TestWithEnviron(t *testing.T) { compiler := New( WithEnviron( map[string]string{ "RACK_ENV": "development", "SHOW": "true", }, ), ) assert.Equal(t, "development", compiler.env["RACK_ENV"]) assert.Equal(t, "true", compiler.env["SHOW"]) } func TestDefaultClonePlugin(t *testing.T) { compiler := New( WithDefaultClonePlugin("not-an-image"), ) assert.Equal(t, "not-an-image", compiler.defaultClonePlugin) } func TestWithTrustedClonePlugins(t *testing.T) { compiler := New(WithTrustedClonePlugins([]string{"not-an-image"})) assert.ElementsMatch(t, []string{"not-an-image"}, compiler.trustedClonePlugins) compiler = New() assert.ElementsMatch(t, constant.TrustedClonePlugins, compiler.trustedClonePlugins) } ================================================ FILE: pipeline/frontend/yaml/compiler/settings/params.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package settings import ( "fmt" "reflect" "strconv" "strings" "codeberg.org/6543/go-yaml2json" "gopkg.in/yaml.v3" ) // ParamsToEnv uses reflection to convert a map[string]interface to a list // of environment variables. func ParamsToEnv(from map[string]any, to map[string]string, prefix string, upper bool, getSecretValue func(name string) (string, error), secretMapping map[string]string) (err error) { if to == nil { return fmt.Errorf("no map to write to") } for k, v := range from { if v == nil || len(k) == 0 { continue } sanitizedParamKey := sanitizeParamKey(prefix, upper, k) secretUsed := false wrappedGetSecretValue := func(name string) (string, error) { secretUsed = true return getSecretValue(name) } to[sanitizedParamKey], err = sanitizeParamValue(v, wrappedGetSecretValue) if err != nil { return err } if secretUsed && secretMapping != nil { secretMapping[sanitizedParamKey] = to[sanitizedParamKey] } } return nil } // sanitizeParamKey formats the environment variable key. func sanitizeParamKey(prefix string, upper bool, k string) string { r := k if upper { r = strings.ReplaceAll(strings.ReplaceAll(k, ".", "_"), "-", "_") r = strings.ToUpper(r) } return prefix + r } // isComplex indicate if a data type can be turned into string without encoding as json. func isComplex(t reflect.Kind) bool { switch t { case reflect.Bool, reflect.String, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: return false default: return true } } // sanitizeParamValue returns the value of a setting as string prepared to be injected as environment variable. func sanitizeParamValue(v any, getSecretValue func(name string) (string, error)) (string, error) { t := reflect.TypeOf(v) vv := reflect.ValueOf(v) switch t.Kind() { case reflect.Bool: return strconv.FormatBool(vv.Bool()), nil case reflect.String: return vv.String(), nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return fmt.Sprintf("%v", vv.Int()), nil case reflect.Float32, reflect.Float64: return fmt.Sprintf("%v", vv.Float()), nil case reflect.Map: switch v := v.(type) { // gopkg.in/yaml.v3 only emits this map interface case map[string]any: // check if it's a secret and return value if it's the case value, isSecret, err := injectSecret(v, getSecretValue) if err != nil { return "", err } else if isSecret { return value, nil } default: return "", fmt.Errorf("could not handle: %#v", v) } return handleComplex(vv.Interface(), getSecretValue) case reflect.Slice, reflect.Array: if vv.Len() == 0 { return "", nil } // if it's an interface unwrap and element check happen for each iteration later if t.Elem().Kind() == reflect.Interface || // else check directly if element is not complex !isComplex(t.Elem().Kind()) { containsComplex := false in := make([]string, vv.Len()) for i := 0; i < vv.Len(); i++ { v := vv.Index(i).Interface() // if we handle a list with a nil entry we just return a empty list if v == nil { continue } // ensure each element is not complex if isComplex(reflect.TypeOf(v).Kind()) { containsComplex = true break } var err error if in[i], err = sanitizeParamValue(v, getSecretValue); err != nil { return "", err } } if !containsComplex { return strings.Join(in, ","), nil } } } // handle all elements which are not primitives, string-maps containing secrets or arrays return handleComplex(vv.Interface(), getSecretValue) } // handleComplex uses yaml2json to get json strings as values for environment variables. func handleComplex(v any, getSecretValue func(name string) (string, error)) (string, error) { v, err := injectSecretRecursive(v, getSecretValue) if err != nil { return "", err } out, err := yaml.Marshal(v) if err != nil { return "", err } out, err = yaml2json.Convert(out) if err != nil { return "", err } return string(out), nil } // injectSecret probes if a map is a from_secret request. // If it's a from_secret request it either returns the secret value or an error if the secret was not found // else it just indicates to progress normally using the provided map as is. func injectSecret(v map[string]any, getSecretValue func(name string) (string, error)) (string, bool, error) { if secretNameI, ok := v["from_secret"]; ok { if secretName, ok := secretNameI.(string); ok { secret, err := getSecretValue(secretName) if err != nil { return "", false, err } return secret, true, nil } return "", false, fmt.Errorf("from_secret has to be a string") } return "", false, nil } // injectSecretRecursive iterates over all types and if they contain elements // it iterates recursively over them too, using injectSecret internally. func injectSecretRecursive(v any, getSecretValue func(name string) (string, error)) (any, error) { t := reflect.TypeOf(v) if t == nil { return v, nil } if !isComplex(t.Kind()) { return v, nil } switch t.Kind() { case reflect.Map: switch v := v.(type) { // gopkg.in/yaml.v3 only emits this map interface case map[string]any: // handle secrets value, isSecret, err := injectSecret(v, getSecretValue) if err != nil { return nil, err } else if isSecret { return value, nil } for key, val := range v { v[key], err = injectSecretRecursive(val, getSecretValue) if err != nil { return nil, err } } return v, nil default: return v, fmt.Errorf("could not handle: %#v", v) } case reflect.Array, reflect.Slice: vv := reflect.ValueOf(v) vl := make([]any, vv.Len()) for i := 0; i < vv.Len(); i++ { v, err := injectSecretRecursive(vv.Index(i).Interface(), getSecretValue) if err != nil { return nil, err } vl[i] = v } return vl, nil default: return v, nil } } ================================================ FILE: pipeline/frontend/yaml/compiler/settings/params_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package settings import ( "fmt" "strings" "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestParamsToEnv(t *testing.T) { from := map[string]any{ "skip": nil, "string": "stringz", "int": 1, "float": 1.2, "bool": true, "slice": []int{1, 2, 3}, "map": map[string]any{"hello": "world"}, "complex": []struct{ Name string }{{"Jack"}, {"Jill"}}, "complex2": struct{ Name string }{"Jack"}, "from.address": "noreply@example.com", "tags": stringsToInterface("next", "latest"), "tag": stringsToInterface("next"), "my_secret": map[string]any{"from_secret": "secret_token"}, "UPPERCASE_SECRET": map[string]any{"from_secret": "SECRET_TOKEN"}, } want := map[string]string{ "PLUGIN_STRING": "stringz", "PLUGIN_INT": "1", "PLUGIN_FLOAT": "1.2", "PLUGIN_BOOL": "true", "PLUGIN_SLICE": "1,2,3", "PLUGIN_MAP": `{"hello":"world"}`, "PLUGIN_COMPLEX": `[{"name":"Jack"},{"name":"Jill"}]`, "PLUGIN_COMPLEX2": `{"name":"Jack"}`, "PLUGIN_FROM_ADDRESS": "noreply@example.com", "PLUGIN_TAG": "next", "PLUGIN_TAGS": "next,latest", "PLUGIN_MY_SECRET": "FooBar", "PLUGIN_UPPERCASE_SECRET": "FooBar", } secrets := map[string]string{ "secret_token": "FooBar", } got := map[string]string{} getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := secrets[name] if ok { return secret, nil } return "", fmt.Errorf("secret %q not found or not allowed to be used", name) } secretMapping := map[string]string{} assert.NoError(t, ParamsToEnv(from, got, "PLUGIN_", true, getSecretValue, secretMapping)) assert.EqualValues(t, want, got, "Problem converting plugin parameters to environment variables") // handle edge cases (#1609) got = map[string]string{} assert.NoError(t, ParamsToEnv(map[string]any{"a": []any{"a", nil}}, got, "PLUGIN_", true, nil, nil)) assert.EqualValues(t, map[string]string{"PLUGIN_A": "a,"}, got) } func TestParamsToEnvPrefix(t *testing.T) { from := map[string]any{ "string": "stringz", "int": 1, } wantPrefixPlugin := map[string]string{ "PLUGIN_STRING": "stringz", "PLUGIN_INT": "1", } got := map[string]string{} getSecretValue := func(name string) (string, error) { return "", fmt.Errorf("secret %q not found or not allowed to be used", name) } assert.NoError(t, ParamsToEnv(from, got, "PLUGIN_", true, getSecretValue, nil)) assert.EqualValues(t, wantPrefixPlugin, got, "Problem converting plugin parameters to environment variables") wantNoPrefix := map[string]string{ "STRING": "stringz", "INT": "1", } // handle edge cases (#1609) got = map[string]string{} assert.NoError(t, ParamsToEnv(from, got, "", true, getSecretValue, nil)) assert.EqualValues(t, wantNoPrefix, got, "Problem converting plugin parameters to environment variables") } func TestSanitizeParamKey(t *testing.T) { assert.EqualValues(t, "PLUGIN_DRY_RUN", sanitizeParamKey("PLUGIN_", true, "dry-run")) assert.EqualValues(t, "PLUGIN_DRY_RUN", sanitizeParamKey("PLUGIN_", true, "dry_Run")) assert.EqualValues(t, "PLUGIN_DRY_RUN", sanitizeParamKey("PLUGIN_", true, "dry.run")) assert.EqualValues(t, "PLUGIN_dry-run", sanitizeParamKey("PLUGIN_", false, "dry-run")) assert.EqualValues(t, "PLUGIN_dry_Run", sanitizeParamKey("PLUGIN_", false, "dry_Run")) assert.EqualValues(t, "PLUGIN_dry.run", sanitizeParamKey("PLUGIN_", false, "dry.run")) } func TestYAMLToParamsToEnv(t *testing.T) { fromYAML := []byte(`skip: ~ string: stringz int: 1 float: 1.2 bool: true slice: [1, 2, 3] my_secret: from_secret: secret_token map: key: "value" entry2: - "a" - "b" - 3 secret: from_secret: secret_token list.map: - registry: https://codeberg.org username: "6543" password: from_secret: cb_password `) var from map[string]any err := yaml.Unmarshal(fromYAML, &from) assert.NoError(t, err) want := map[string]string{ "PLUGIN_STRING": "stringz", "PLUGIN_INT": "1", "PLUGIN_FLOAT": "1.2", "PLUGIN_BOOL": "true", "PLUGIN_SLICE": "1,2,3", "PLUGIN_MY_SECRET": "FooBar", "PLUGIN_MAP": `{"entry2":["a","b",3],"key":"value","secret":"FooBar"}`, "PLUGIN_LIST_MAP": `[{"password":"geheim","registry":"https://codeberg.org","username":"6543"}]`, } secrets := map[string]string{ "secret_token": "FooBar", "cb_password": "geheim", } got := map[string]string{} getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := secrets[name] if ok { return secret, nil } return "", fmt.Errorf("secret %q not found or not allowed to be used", name) } gotSecretMapping := map[string]string{} wantSecretMapping := map[string]string{ "PLUGIN_MY_SECRET": "FooBar", "PLUGIN_MAP": `{"entry2":["a","b",3],"key":"value","secret":"FooBar"}`, "PLUGIN_LIST_MAP": `[{"password":"geheim","registry":"https://codeberg.org","username":"6543"}]`, } assert.NoError(t, ParamsToEnv(from, got, "PLUGIN_", true, getSecretValue, gotSecretMapping)) assert.Equal(t, wantSecretMapping, gotSecretMapping, "Problem collecting secret mapping") assert.EqualValues(t, want, got, "Problem converting plugin parameters to environment variables") } func TestYAMLToParamsToEnvError(t *testing.T) { fromYAML := []byte(`my_secret: from_secret: not_a_secret `) var from map[string]any err := yaml.Unmarshal(fromYAML, &from) assert.NoError(t, err) secrets := map[string]string{ "secret_token": "FooBar", } getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := secrets[name] if ok { return secret, nil } return "", fmt.Errorf("secret %q not found or not allowed to be used", name) } secretMapping := map[string]string{} assert.Error(t, ParamsToEnv(from, make(map[string]string), "PLUGIN_", true, getSecretValue, secretMapping)) } func stringsToInterface(val ...string) []any { res := make([]any, len(val)) for i := range val { res[i] = val[i] } return res } func TestSecretNotFound(t *testing.T) { from := map[string]any{ "map": map[string]any{"secret": map[string]any{"from_secret": "secret_token"}}, } secrets := map[string]string{ "a_different_password": "secret", } getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := secrets[name] if ok { return secret, nil } return "", fmt.Errorf("secret %q not found or not allowed to be used", name) } got := map[string]string{} secretMapping := map[string]string{} assert.ErrorContains(t, ParamsToEnv(from, got, "PLUGIN_", true, getSecretValue, secretMapping), fmt.Sprintf("secret %q not found or not allowed to be used", "secret_token")) } func TestSecretMappingSimpleSecret(t *testing.T) { from := map[string]any{ "simple_secret": map[string]any{"from_secret": "my_token"}, "regular_var": "no_secret_here", } secrets := map[string]string{ "my_token": "secret_value_123", } getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := secrets[name] if ok { return secret, nil } return "", fmt.Errorf("secret %q not found", name) } got := map[string]string{} secretMapping := map[string]string{} assert.NoError(t, ParamsToEnv(from, got, "PLUGIN_", true, getSecretValue, secretMapping)) assert.Equal(t, "secret_value_123", got["PLUGIN_SIMPLE_SECRET"]) assert.Equal(t, "no_secret_here", got["PLUGIN_REGULAR_VAR"]) assert.Equal(t, "secret_value_123", secretMapping["PLUGIN_SIMPLE_SECRET"]) assert.NotContains(t, secretMapping, "PLUGIN_REGULAR_VAR") } func TestSecretMappingComplexMapWithSecrets(t *testing.T) { from := map[string]any{ "config": map[string]any{ "database": map[string]any{ "host": "localhost", "password": map[string]any{"from_secret": "db_password"}, "port": 5432, }, "api_key": map[string]any{"from_secret": "api_secret"}, "timeout": 30, }, "simple_var": "no_secrets", } secrets := map[string]string{ "db_password": "super_secret_db_pass", "api_secret": "api_key_12345", } getSecretValue := func(name string) (string, error) { name = strings.ToLower(name) secret, ok := secrets[name] if ok { return secret, nil } return "", fmt.Errorf("secret %q not found", name) } got := map[string]string{} secretMapping := map[string]string{} assert.NoError(t, ParamsToEnv(from, got, "PLUGIN_", true, getSecretValue, secretMapping)) expectedJSON := `{"api_key":"api_key_12345","database":{"host":"localhost","password":"super_secret_db_pass","port":5432},"timeout":30}` assert.Equal(t, expectedJSON, got["PLUGIN_CONFIG"]) assert.Equal(t, "no_secrets", got["PLUGIN_SIMPLE_VAR"]) assert.Equal(t, expectedJSON, secretMapping["PLUGIN_CONFIG"]) assert.NotContains(t, secretMapping, "PLUGIN_SIMPLE_VAR") } func TestComplexTypesWithNilValuesWontPanic(t *testing.T) { from := map[string]any{ "config": []any{ "copy a b", map[string]any{ "foo": nil, }, }, } got := map[string]string{} expectedJSON := `["copy a b",{"foo":null}]` err := ParamsToEnv(from, got, "PLUGIN_", true, nil, nil) assert.NoError(t, err) assert.Equal(t, expectedJSON, got["PLUGIN_CONFIG"]) } ================================================ FILE: pipeline/frontend/yaml/constraint/constraint.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "fmt" "maps" "path" "slices" "github.com/expr-lang/expr" "gopkg.in/yaml.v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" yaml_base_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" "go.woodpecker-ci.org/woodpecker/v3/shared/optional" ) const ( statusFailure = "failure" statusSuccess = "success" ) type ( // When defines a set of runtime constraints. When struct { // If true then read from a list of constraint Constraints []Constraint } Constraint struct { Ref List `yaml:"ref,omitempty"` Repo List `yaml:"repo,omitempty"` Instance List `yaml:"instance,omitempty"` Platform List `yaml:"platform,omitempty"` Branch List `yaml:"branch,omitempty"` Cron List `yaml:"cron,omitempty"` Status yaml_base_types.StringOrSlice `yaml:"status,omitempty"` Matrix Map `yaml:"matrix,omitempty"` Local optional.Option[bool] `yaml:"local,omitempty"` Path Path `yaml:"path,omitempty"` Evaluate string `yaml:"evaluate,omitempty"` Event yaml_base_types.StringOrSlice `yaml:"event,omitempty"` } ) func (when *When) IsEmpty() bool { return len(when.Constraints) == 0 } // Returns true if at least one of the internal constraints is true. func (when *When) Match(metadata metadata.Metadata, global bool, env map[string]string) (bool, error) { for _, c := range when.Constraints { match, err := c.Match(metadata, global, env) if err != nil { return false, err } if match { return true, nil } } if when.IsEmpty() { // test against default Constraints empty := &Constraint{} return empty.Match(metadata, global, env) } return false, nil } func (when *When) IncludesStatusFailure(metadata metadata.Metadata, global bool, env map[string]string) bool { if when.IsEmpty() { return false } for _, c := range when.Constraints { if matches, err := c.Match(metadata, global, env); err == nil && matches { if slices.Contains(c.Status, statusFailure) { return true } } } return false } func (when *When) IncludesStatusSuccess(metadata metadata.Metadata, global bool, env map[string]string) bool { // "success" acts differently than "failure" in that it's // presumed to be included unless it's specifically not part // of the list if when.IsEmpty() { return true } for _, c := range when.Constraints { if matches, err := c.Match(metadata, global, env); err == nil && matches { if len(c.Status) == 0 || slices.Contains(c.Status, statusSuccess) { return true } } } return false } // False if (any) non local. func (when *When) IsLocal() bool { for _, c := range when.Constraints { if !c.Local.ValueOrDefault(true) { return false } } return true } func (when *When) UnmarshalYAML(value *yaml.Node) error { switch value.Kind { case yaml.SequenceNode: if err := value.Decode(&when.Constraints); err != nil { return err } case yaml.MappingNode: c := Constraint{} if err := value.Decode(&c); err != nil { return err } when.Constraints = append(when.Constraints, c) default: return fmt.Errorf("not supported yaml kind: %v", value.Kind) } return nil } // MarshalYAML implements custom Yaml marshaling. func (when When) MarshalYAML() (any, error) { // clean up local if true make it none as we will default to true for i := range when.Constraints { if when.Constraints[i].Local.ValueOrDefault(true) { when.Constraints[i].Local = optional.None[bool]() } } switch len(when.Constraints) { case 0: return nil, nil case 1: return when.Constraints[0], nil default: return when.Constraints, nil } } // Match returns true if all constraints match the given input. If a single // constraint fails a false value is returned. func (c *Constraint) Match(m metadata.Metadata, global bool, env map[string]string) (bool, error) { match := true if !global { // apply step only filters match = c.Matrix.Match(m.Workflow.Matrix) } match = match && c.Platform.Match(m.Sys.Platform) && (len(c.Event) == 0 || slices.Contains(c.Event, string(m.Curr.Event))) && c.Repo.Match(path.Join(m.Repo.Owner, m.Repo.Name)) && c.Ref.Match(m.Curr.Commit.Ref) && c.Instance.Match(m.Sys.Host) // changed files filter apply only for pull-request and push events if m.Curr.Event.IsPull() || m.Curr.Event == metadata.EventPush { match = match && c.Path.Match(m.Curr.Commit.ChangedFiles, m.Curr.Commit.Message) } if m.Curr.Event != metadata.EventTag { match = match && c.Branch.Match(m.Curr.Commit.Branch) } if m.Curr.Event == metadata.EventCron { match = match && c.Cron.Match(m.Curr.Cron) } if c.Evaluate != "" { if env == nil { env = m.Environ() } else { maps.Copy(env, m.Environ()) } out, err := expr.Compile(c.Evaluate, expr.Env(env), expr.AllowUndefinedVariables(), expr.AsBool()) if err != nil { return false, err } result, err := expr.Run(out, env) if err != nil { return false, err } bResult, ok := result.(bool) if !ok { return false, fmt.Errorf("could not parse result: %v", result) } match = match && bResult } return match, nil } ================================================ FILE: pipeline/frontend/yaml/constraint/constraint_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" ) func TestConstraintStatusSuccessFailure(t *testing.T) { testdata := []struct { conf string wantSuccess bool wantFail bool }{ {conf: "", wantSuccess: true, wantFail: false}, {conf: "{status: [failure]}", wantSuccess: false, wantFail: true}, {conf: "{status: [success]}", wantSuccess: true, wantFail: false}, {conf: "{status: [failure, success]}", wantSuccess: true, wantFail: true}, {conf: "{event: push, status: [failure, success]}", wantSuccess: false, wantFail: false}, {conf: "{event: pull_request, status: [failure, success]}", wantSuccess: true, wantFail: true}, {conf: "{event: push, status: failure}", wantSuccess: false, wantFail: false}, {conf: "{event: pull_request, status: [failure]}", wantSuccess: false, wantFail: true}, {conf: "{status: success}", wantSuccess: true, wantFail: false}, {conf: "[{}]", wantSuccess: true, wantFail: false}, {conf: "[{status: success}]", wantSuccess: true, wantFail: false}, {conf: "[{},{status: failure}]", wantSuccess: true, wantFail: true}, {conf: "[{event: push, status: success},{status: failure}]", wantSuccess: false, wantFail: true}, {conf: "[{status: failure},{event: push, status: success}]", wantSuccess: false, wantFail: true}, } for _, test := range testdata { t.Run(test.conf, func(t *testing.T) { c := parseConstraints(t, test.conf) assert.Equalf(t, test.wantSuccess, c.IncludesStatusSuccess(metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPull}}, true, map[string]string{}), "include success is wrong for when: '%s'", test.conf) assert.Equal(t, test.wantFail, c.IncludesStatusFailure(metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPull}}, true, map[string]string{}), "include fail is wrong for when: '%s'", test.conf) }) } } func TestConstraints(t *testing.T) { testdata := []struct { desc string conf string with metadata.Metadata env map[string]string want bool }{ { desc: "no constraints, must match on default events", conf: "", with: metadata.Metadata{ Curr: metadata.Pipeline{ Event: metadata.EventPush, }, }, want: true, }, { desc: "global branch filter", conf: "{ branch: develop }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush, Commit: metadata.Commit{Branch: "main"}}}, want: false, }, { desc: "global branch filter", conf: "{ branch: main }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush, Commit: metadata.Commit{Branch: "main"}}}, want: true, }, { desc: "repo constraint", conf: "{ repo: owner/* }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Repo: metadata.Repo{Owner: "owner", Name: "repo"}}, want: true, }, { desc: "repo constraint", conf: "{ repo: octocat/* }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Repo: metadata.Repo{Owner: "owner", Name: "repo"}}, want: false, }, { desc: "ref constraint", conf: "{ ref: refs/tags/* }", with: metadata.Metadata{Curr: metadata.Pipeline{Commit: metadata.Commit{Ref: "refs/tags/v1.0.0"}, Event: metadata.EventPush}}, want: true, }, { desc: "ref constraint", conf: "{ ref: refs/tags/* }", with: metadata.Metadata{Curr: metadata.Pipeline{Commit: metadata.Commit{Ref: "refs/heads/main"}, Event: metadata.EventPush}}, want: false, }, { desc: "platform constraint", conf: "{ platform: linux/amd64 }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Platform: "linux/amd64"}}, want: true, }, { desc: "platform constraint", conf: "{ repo: linux/amd64 }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Platform: "windows/amd64"}}, want: false, }, { desc: "instance constraint", conf: "{ instance: agent.tld }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Host: "agent.tld"}}, want: true, }, { desc: "instance constraint", conf: "{ instance: agent.tld }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Sys: metadata.System{Host: "beta.agent.tld"}}, want: false, }, { desc: "filter cron by matching name", conf: "{ event: cron, cron: job1 }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventCron, Cron: "job1"}}, want: true, }, { desc: "filter cron by name", conf: "{ event: cron, cron: job2 }", with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventCron, Cron: "job1"}}, want: false, }, { desc: "filter with build-in env passes", conf: "{ branch: ${CI_REPO_DEFAULT_BRANCH} }", with: metadata.Metadata{ Curr: metadata.Pipeline{Event: metadata.EventPush, Commit: metadata.Commit{Branch: "stable"}}, Repo: metadata.Repo{Branch: "stable"}, }, want: true, }, { desc: "filter by eval based on event", conf: `{ evaluate: 'CI_PIPELINE_EVENT == "push"' }`, with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}}, want: true, }, { desc: "filter by eval based on event and repo", conf: `{ evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo"' }`, with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventPush}, Repo: metadata.Repo{Owner: "owner", Name: "repo"}}, want: true, }, { desc: "filter by eval based on custom variable", conf: `{ evaluate: 'TESTVAR == "testval"' }`, with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventManual}}, env: map[string]string{"TESTVAR": "testval"}, want: true, }, { desc: "filter by eval based on custom variable", conf: `{ evaluate: 'TESTVAR == "testval"' }`, with: metadata.Metadata{Curr: metadata.Pipeline{Event: metadata.EventManual}}, env: map[string]string{"TESTVAR": "qwe"}, want: false, }, } for _, test := range testdata { t.Run(test.desc, func(t *testing.T) { conf, err := metadata.EnvVarSubst(test.conf, test.with.Environ()) assert.NoError(t, err) c := parseConstraints(t, conf) got, err := c.Match(test.with, false, test.env) assert.NoError(t, err) assert.Equal(t, test.want, got) }) } } func parseConstraints(t *testing.T, s string) *When { t.Helper() c := &When{} require.NoError(t, yaml.Unmarshal([]byte(s), c)) return c } ================================================ FILE: pipeline/frontend/yaml/constraint/list.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "fmt" "github.com/bmatcuk/doublestar/v4" "go.uber.org/multierr" "gopkg.in/yaml.v3" yaml_base_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" ) // List defines a runtime constraint for exclude & include string slices. type List struct { Include []string Exclude []string } // IsEmpty return true if a constraint has no conditions. func (c List) IsEmpty() bool { return len(c.Include) == 0 && len(c.Exclude) == 0 } // Match returns true if the string matches the include patterns and does not // match any of the exclude patterns. func (c *List) Match(v string) bool { if c == nil { return true } if c.Excludes(v) { return false } if c.Includes(v) { return true } if len(c.Include) == 0 { return true } return false } // Includes returns true if the string matches the include patterns. func (c *List) Includes(v string) bool { for _, pattern := range c.Include { if ok, _ := doublestar.Match(pattern, v); ok { return true } } return false } // Excludes returns true if the string matches the exclude patterns. func (c *List) Excludes(v string) bool { for _, pattern := range c.Exclude { if ok, _ := doublestar.Match(pattern, v); ok { return true } } return false } // UnmarshalYAML unmarshal the constraint. func (c *List) UnmarshalYAML(value *yaml.Node) error { out1 := struct { Include yaml_base_types.StringOrSlice Exclude yaml_base_types.StringOrSlice }{} var out2 yaml_base_types.StringOrSlice err1 := value.Decode(&out1) err2 := value.Decode(&out2) c.Exclude = out1.Exclude c.Include = append( //nolint:gocritic out1.Include, out2..., ) if err1 != nil && err2 != nil { y, _ := yaml.Marshal(value) return fmt.Errorf("could not parse condition: %s: %w", y, multierr.Append(err1, err2)) } return nil } // MarshalYAML implements custom Yaml marshaling. func (c List) MarshalYAML() (any, error) { switch { case len(c.Include) == 0 && len(c.Exclude) == 0: return nil, nil case len(c.Exclude) == 0: return yaml_base_types.StringOrSlice(c.Include), nil default: // we can not return type List as it would lead to infinite recursion :/ return struct { Include yaml_base_types.StringOrSlice `yaml:"include,omitempty"` Exclude yaml_base_types.StringOrSlice `yaml:"exclude,omitempty"` }{ Include: c.Include, Exclude: c.Exclude, }, nil } } ================================================ FILE: pipeline/frontend/yaml/constraint/list_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestConstraintList(t *testing.T) { testdata := []struct { conf string with string want bool }{ // string value { conf: "main", with: "develop", want: false, }, { conf: "main", with: "main", want: true, }, { conf: "feature/*", with: "feature/foo", want: true, }, // slice value { conf: "[ main, feature/* ]", with: "develop", want: false, }, { conf: "[ main, feature/* ]", with: "main", want: true, }, { conf: "[ main, feature/* ]", with: "feature/foo", want: true, }, // includes block { conf: "include: main", with: "develop", want: false, }, { conf: "include: main", with: "main", want: true, }, { conf: "include: feature/*", with: "main", want: false, }, { conf: "include: feature/*", with: "feature/foo", want: true, }, { conf: "include: [ main, feature/* ]", with: "develop", want: false, }, { conf: "include: [ main, feature/* ]", with: "main", want: true, }, { conf: "include: [ main, feature/* ]", with: "feature/foo", want: true, }, // excludes block { conf: "exclude: main", with: "develop", want: true, }, { conf: "exclude: main", with: "main", want: false, }, { conf: "exclude: feature/*", with: "main", want: true, }, { conf: "exclude: feature/*", with: "feature/foo", want: false, }, { conf: "exclude: [ main, develop ]", with: "main", want: false, }, { conf: "exclude: [ feature/*, bar ]", with: "main", want: true, }, { conf: "exclude: [ feature/*, bar ]", with: "feature/foo", want: false, }, // include and exclude blocks { conf: "{ include: [ main, feature/* ], exclude: [ develop ] }", with: "main", want: true, }, { conf: "{ include: [ main, feature/* ], exclude: [ feature/bar ] }", with: "feature/bar", want: false, }, { conf: "{ include: [ main, feature/* ], exclude: [ main, develop ] }", with: "main", want: false, }, // empty blocks { conf: "", with: "main", want: true, }, } for _, test := range testdata { c := parseConstraintList(t, test.conf) assert.Equal(t, test.want, c.Match(test.with)) } } func parseConstraintList(t *testing.T, s string) *List { c := &List{} assert.NoError(t, yaml.Unmarshal([]byte(s), c)) return c } ================================================ FILE: pipeline/frontend/yaml/constraint/map.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import "github.com/bmatcuk/doublestar/v4" // Map defines a runtime constraint for exclude & include map strings. type Map struct { Include map[string]string `yaml:"include,omitempty"` Exclude map[string]string `yaml:"exclude,omitempty"` } // Match returns true if the params matches the include key values and does not // match any of the exclude key values. func (c *Map) Match(params map[string]string) bool { // when no includes or excludes automatically match if c == nil || len(c.Include) == 0 && len(c.Exclude) == 0 { return true } // Exclusions are processed first. So we can include everything and then // selectively include others. if len(c.Exclude) != 0 { var matches int for key, val := range c.Exclude { if ok, _ := doublestar.Match(val, params[key]); ok { matches++ } } if matches == len(c.Exclude) { return false } } for key, val := range c.Include { if ok, _ := doublestar.Match(val, params[key]); !ok { return false } } return true } // UnmarshalYAML unmarshal the constraint map. func (c *Map) UnmarshalYAML(unmarshal func(any) error) error { out1 := struct { Include map[string]string Exclude map[string]string }{ Include: map[string]string{}, Exclude: map[string]string{}, } out2 := map[string]string{} _ = unmarshal(&out1) // it contains include and exclude statement _ = unmarshal(&out2) // it contains no include/exclude statement, assume include as default c.Include = out1.Include c.Exclude = out1.Exclude for k, v := range out2 { c.Include[k] = v } return nil } // MarshalYAML implements custom Yaml marshaling. func (c Map) MarshalYAML() (any, error) { switch { case len(c.Include) == 0 && len(c.Exclude) == 0: return nil, nil case len(c.Exclude) == 0: return c.Include, nil case len(c.Include) == 0 && len(c.Exclude) != 0: return struct { Exclude map[string]string }{Exclude: c.Exclude}, nil default: // we can not return type Map as it would lead to infinite recursion :/ return struct { Include map[string]string `yaml:"include,omitempty"` Exclude map[string]string `yaml:"exclude,omitempty"` }{ Include: c.Include, Exclude: c.Exclude, }, nil } } ================================================ FILE: pipeline/frontend/yaml/constraint/map_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestConstraintMap(t *testing.T) { testdata := []struct { conf string with map[string]string want bool }{ { conf: "GOLANG: 1.7", with: map[string]string{"GOLANG": "1.7"}, want: true, }, { conf: "GOLANG: tip", with: map[string]string{"GOLANG": "1.7"}, want: false, }, { conf: "{ GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.1", "MYSQL": "5.6"}, want: true, }, { conf: "{ GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.0"}, want: false, }, { conf: "{ GOLANG: 1.7, REDIS: 3.* }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.0"}, want: true, }, { conf: "{ GOLANG: 1.7, BRANCH: release/**/test }", with: map[string]string{"GOLANG": "1.7", "BRANCH": "release/v1.12.1//test"}, want: true, }, { conf: "{ GOLANG: 1.7, BRANCH: release/**/test }", with: map[string]string{"GOLANG": "1.7", "BRANCH": "release/v1.12.1/qest"}, want: false, }, // include syntax { conf: "include: { GOLANG: 1.7 }", with: map[string]string{"GOLANG": "1.7"}, want: true, }, { conf: "include: { GOLANG: tip }", with: map[string]string{"GOLANG": "1.7"}, want: false, }, { conf: "include: { GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.1", "MYSQL": "5.6"}, want: true, }, { conf: "include: { GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.0"}, want: false, }, // exclude syntax { conf: "exclude: { GOLANG: 1.7 }", with: map[string]string{"GOLANG": "1.7"}, want: false, }, { conf: "exclude: { GOLANG: tip }", with: map[string]string{"GOLANG": "1.7"}, want: true, }, { conf: "exclude: { GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.1", "MYSQL": "5.6"}, want: false, }, { conf: "exclude: { GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.0"}, want: true, }, // exclude AND include values { conf: "{ include: { GOLANG: 1.7 }, exclude: { GOLANG: 1.7 } }", with: map[string]string{"GOLANG": "1.7"}, want: false, }, // blanks { conf: "", with: map[string]string{"GOLANG": "1.7", "REDIS": "3.0"}, want: true, }, { conf: "GOLANG: 1.7", with: map[string]string{}, want: false, }, { conf: "{ GOLANG: 1.7, REDIS: 3.0 }", with: map[string]string{}, want: false, }, { conf: "include: { GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{}, want: false, }, { conf: "exclude: { GOLANG: 1.7, REDIS: 3.1 }", with: map[string]string{}, want: true, }, } for _, test := range testdata { c := parseConstraintMap(t, test.conf) assert.Equal(t, test.want, c.Match(test.with), "config: '%s', with: '%s'", test.conf, test.with) } } func parseConstraintMap(t *testing.T, s string) *Map { c := &Map{} assert.NoError(t, yaml.Unmarshal([]byte(s), c)) return c } ================================================ FILE: pipeline/frontend/yaml/constraint/path.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "fmt" "strings" "github.com/bmatcuk/doublestar/v4" "gopkg.in/yaml.v3" yaml_base_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" "go.woodpecker-ci.org/woodpecker/v3/shared/optional" ) // Path defines a runtime constrain for exclude & include paths. type Path struct { Include []string `yaml:"include,omitempty"` Exclude []string `yaml:"exclude,omitempty"` IgnoreMessage string `yaml:"ignore_message,omitempty"` OnEmpty optional.Option[bool] `yaml:"on_empty,omitempty"` } // UnmarshalYAML unmarshal the constraint. func (c *Path) UnmarshalYAML(value *yaml.Node) error { out1 := struct { Include yaml_base_types.StringOrSlice `yaml:"include"` Exclude yaml_base_types.StringOrSlice `yaml:"exclude"` IgnoreMessage string `yaml:"ignore_message"` OnEmpty optional.Option[bool] `yaml:"on_empty"` }{} var out2 yaml_base_types.StringOrSlice err1 := value.Decode(&out1) err2 := value.Decode(&out2) c.Exclude = out1.Exclude c.IgnoreMessage = out1.IgnoreMessage c.OnEmpty = out1.OnEmpty c.Include = append( //nolint:gocritic out1.Include, out2..., ) if err1 != nil && err2 != nil { y, _ := yaml.Marshal(value) return fmt.Errorf("could not parse condition: %s", y) } return nil } // MarshalYAML implements custom Yaml marshaling. func (c Path) MarshalYAML() (any, error) { // if only Include is set return simple syntax if len(c.Exclude) == 0 && len(c.IgnoreMessage) == 0 && c.OnEmpty.ValueOrDefault(true) { if len(c.Include) == 0 { return nil, nil } return yaml_base_types.StringOrSlice(c.Include), nil } // clean up on_empty if true make it none as we will default to true if c.OnEmpty.ValueOrDefault(true) { c.OnEmpty = optional.None[bool]() } // we can not return type Path as it would lead to infinite recursion :/ return struct { Include yaml_base_types.StringOrSlice `yaml:"include,omitempty"` Exclude yaml_base_types.StringOrSlice `yaml:"exclude,omitempty"` IgnoreMessage string `yaml:"ignore_message,omitempty"` OnEmpty optional.Option[bool] `yaml:"on_empty,omitempty"` }{ Include: c.Include, Exclude: c.Exclude, IgnoreMessage: c.IgnoreMessage, OnEmpty: c.OnEmpty, }, nil } // Match returns true if file paths in string slice matches the include and not exclude patterns // or if commit message contains ignore message. func (c *Path) Match(v []string, message string) bool { // ignore file pattern matches if the commit message contains a pattern if len(c.IgnoreMessage) > 0 && strings.Contains(strings.ToLower(message), strings.ToLower(c.IgnoreMessage)) { return true } // return value based on 'on_empty', if there are no commit files (empty commit) if len(v) == 0 { return c.OnEmpty.ValueOrDefault(true) } if len(c.Exclude) > 0 && c.Excludes(v) { return false } if len(c.Include) > 0 && !c.Includes(v) { return false } return true } // Includes returns true if the string matches any of the include patterns. func (c *Path) Includes(v []string) bool { for _, pattern := range c.Include { for _, file := range v { if ok, _ := doublestar.Match(pattern, file); ok { return true } } } return false } // Excludes returns true if all of the strings match any of the exclude patterns. func (c *Path) Excludes(v []string) bool { for _, file := range v { matched := false for _, pattern := range c.Exclude { if ok, _ := doublestar.Match(pattern, file); ok { matched = true break } } if !matched { return false } } return true } ================================================ FILE: pipeline/frontend/yaml/constraint/path_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestConstraintPath(t *testing.T) { testdata := []struct { conf string with []string message string want bool }{ { conf: "", with: []string{"CHANGELOG.md", "README.md"}, want: true, }, { conf: "CHANGELOG.md", with: []string{"CHANGELOG.md", "README.md"}, want: true, }, { conf: "'*.md'", with: []string{"CHANGELOG.md", "README.md"}, want: true, }, { conf: "['*.md']", with: []string{"CHANGELOG.md", "README.md"}, want: true, }, { conf: "'docs/*'", with: []string{"docs/README.md"}, want: true, }, { conf: "'docs/*'", with: []string{"docs/sub/README.md"}, want: false, }, { conf: "'docs/**'", with: []string{"docs/README.md", "docs/sub/README.md", "docs/sub-sub/README.md"}, want: true, }, { conf: "'docs/**'", with: []string{"README.md"}, want: false, }, { conf: "{ include: [ README.md ] }", with: []string{"CHANGELOG.md"}, want: false, }, { conf: "{ exclude: [ README.md ] }", with: []string{"design.md"}, want: true, }, // include and exclude blocks { conf: "{ include: [ '*.md', '*.ini' ], exclude: [ CHANGELOG.md ] }", with: []string{"README.md"}, want: true, }, { conf: "{ include: [ '*.md' ], exclude: [ CHANGELOG.md ] }", with: []string{"CHANGELOG.md"}, want: false, }, { conf: "{ include: [ '*.md' ], exclude: [ CHANGELOG.md ] }", with: []string{"README.md", "CHANGELOG.md"}, want: true, }, { conf: "{ exclude: [ CHANGELOG.md ] }", with: []string{"README.md", "CHANGELOG.md"}, want: true, }, { conf: "{ exclude: [ CHANGELOG.md, docs/**/*.md ] }", with: []string{"docs/main.md", "CHANGELOG.md"}, want: false, }, { conf: "{ exclude: [ CHANGELOG.md, docs/**/*.md ] }", with: []string{"docs/main.md", "CHANGELOG.md", "README.md"}, want: true, }, // commit message ignore matches { conf: "{ include: [ README.md ], ignore_message: '[ALL]' }", with: []string{"CHANGELOG.md"}, message: "Build them [ALL]", want: true, }, { conf: "{ exclude: [ '*.php' ], ignore_message: '[ALL]' }", with: []string{"myfile.php"}, message: "Build them [ALL]", want: true, }, { conf: "{ ignore_message: '[ALL]' }", with: []string{}, message: "Build them [ALL]", want: true, }, // empty commit { conf: "{ include: [ README.md ] }", with: []string{}, want: true, }, { conf: "{ include: [ README.md ], on_empty: false }", with: []string{}, want: false, }, { conf: "{ include: [ README.md ], on_empty: true }", with: []string{}, want: true, }, } for _, test := range testdata { c := parseConstraintPath(t, test.conf) assert.Equal(t, test.want, c.Match(test.with, test.message)) } } func parseConstraintPath(t *testing.T, s string) *Path { c := &Path{} assert.NoError(t, yaml.Unmarshal([]byte(s), c)) return c } ================================================ FILE: pipeline/frontend/yaml/constraint/skip.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constraint import ( "regexp" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" ) var skipPipelineRegex = regexp.MustCompile(`\[(?i:ci *skip|skip *ci)\]`) func IsSkipCommitMessage(event metadata.Event, commitMessage string) bool { if event == metadata.EventPush || event.IsPull() { skipMatch := skipPipelineRegex.FindString(commitMessage) if len(skipMatch) > 0 { return true } } return false } ================================================ FILE: pipeline/frontend/yaml/linter/error.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package linter import ( pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" ) func newLinterError(message, file, field string, isWarning bool) *pipeline_errors.PipelineError { return &pipeline_errors.PipelineError{ Type: pipeline_errors.PipelineErrorTypeLinter, Message: message, Data: &pipeline_errors.LinterErrorData{File: file, Field: field}, IsWarning: isWarning, } } ================================================ FILE: pipeline/frontend/yaml/linter/linter.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package linter import ( "fmt" "codeberg.org/6543/xyaml" "go.uber.org/multierr" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter/schema" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) // networkModeNone is a const we use to check to allow to drop network completely // this should be exempt from privileged action as it makes the container even more unprivileged. const networkModeNone = "none" // A Linter lints a pipeline configuration. type Linter struct { trusted TrustedConfiguration privilegedPlugins *[]string trustedClonePlugins *[]string } type TrustedConfiguration struct { Network bool Volumes bool Security bool } // New creates a new Linter with options. func New(opts ...Option) *Linter { linter := new(Linter) for _, opt := range opts { opt(linter) } return linter } type WorkflowConfig struct { // File is the path to the configuration file. File string // RawConfig is the raw configuration. RawConfig string // Config is the parsed configuration. Workflow *types.Workflow } // Lint lints the configuration. func (l *Linter) Lint(configs []*WorkflowConfig) error { var linterErr error for _, config := range configs { if err := l.lintFile(config); err != nil { linterErr = multierr.Append(linterErr, err) } } return linterErr } func (l *Linter) lintFile(config *WorkflowConfig) error { var linterErr error if len(config.Workflow.Steps.ContainerList) == 0 { linterErr = multierr.Append(linterErr, newLinterError("Invalid or missing `steps` section", config.File, "steps", false)) } if err := l.lintCloneSteps(config); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainers(config, "clone"); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainers(config, "steps"); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainers(config, "services"); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintSchema(config); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintDeprecations(config); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintBadHabits(config); err != nil { linterErr = multierr.Append(linterErr, err) } return linterErr } func (l *Linter) lintCloneSteps(config *WorkflowConfig) error { if len(config.Workflow.Clone.ContainerList) == 0 { return nil } trustedClonePlugins := constant.TrustedClonePlugins if l.trustedClonePlugins != nil { trustedClonePlugins = *l.trustedClonePlugins } var linterErr error for _, container := range config.Workflow.Clone.ContainerList { if !utils.MatchImageDynamic(container.Image, trustedClonePlugins...) { linterErr = multierr.Append(linterErr, newLinterError( "Specified clone image does not match allow list, netrc is not injected", config.File, fmt.Sprintf("clone.%s", container.Name), true), ) } } return linterErr } func (l *Linter) lintContainers(config *WorkflowConfig, area string) error { var linterErr error var containers []*types.Container switch area { case "clone": containers = config.Workflow.Clone.ContainerList case "steps": containers = config.Workflow.Steps.ContainerList case "services": containers = config.Workflow.Services.ContainerList } for _, container := range containers { if err := l.lintImage(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintTrusted(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintSettings(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintPrivilegedPlugins(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintContainerDeprecations(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } if err := l.lintDependsOn(config, container, area); err != nil { linterErr = multierr.Append(linterErr, err) } } return linterErr } func (l *Linter) lintDependsOn(config *WorkflowConfig, c *types.Container, area string) error { if area != "steps" { return nil } var linterErr error check: for _, dep := range c.DependsOn { for _, step := range config.Workflow.Steps.ContainerList { if dep == step.Name { continue check } } linterErr = multierr.Append(linterErr, newLinterError( "One or more of the specified dependencies do not exist", config.File, fmt.Sprintf("%s.%s.depends_on", area, c.Name), false, ), ) } return linterErr } func (l *Linter) lintImage(config *WorkflowConfig, c *types.Container, area string) error { if len(c.Image) == 0 { return newLinterError("Invalid or missing image", config.File, fmt.Sprintf("%s.%s", area, c.Name), false) } return nil } func (l *Linter) lintPrivilegedPlugins(config *WorkflowConfig, c *types.Container, area string) error { // lint for conflicts of https://github.com/woodpecker-ci/woodpecker/pull/3918 if utils.MatchImage(c.Image, "plugins/docker", "plugins/gcr", "plugins/ecr", "woodpeckerci/plugin-docker-buildx") && !c.Privileged { msg := fmt.Sprintf("The formerly privileged plugin `%s` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`", c.Image) // check first if user did not add them back if l.privilegedPlugins != nil && !utils.MatchImageDynamic(c.Image, *l.privilegedPlugins...) { return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), false) } else if l.privilegedPlugins == nil { // if linter has no info of current privileged plugins, it's just a warning return newLinterError(msg, config.File, fmt.Sprintf("%s.%s", area, c.Name), true) } } return nil } func (l *Linter) lintSettings(config *WorkflowConfig, c *types.Container, field string) error { if len(c.Settings) == 0 { return nil } if len(c.Commands) != 0 { return newLinterError("Cannot configure both `commands` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false) } if len(c.Entrypoint) != 0 { return newLinterError("Cannot configure both `entrypoint` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), false) } if len(c.Environment) != 0 { return newLinterError("Should not configure both `environment` and `settings`", config.File, fmt.Sprintf("%s.%s", field, c.Name), true) } return nil } func (l *Linter) lintContainerDeprecations(config *WorkflowConfig, c *types.Container, field string) error { return nil } func (l *Linter) lintTrusted(config *WorkflowConfig, c *types.Container, area string) error { yamlPath := fmt.Sprintf("%s.%s", area, c.Name) errors := []string{} if !l.trusted.Security { if c.Privileged { errors = append(errors, "Insufficient trust level to use `privileged` mode") } } if !l.trusted.Network { if len(c.DNS) != 0 { errors = append(errors, "Insufficient trust level to use custom `dns`") } if len(c.DNSSearch) != 0 { errors = append(errors, "Insufficient trust level to use `dns_search`") } if len(c.ExtraHosts) != 0 { errors = append(errors, "Insufficient trust level to use `extra_hosts`") } if len(c.NetworkMode) != 0 && c.NetworkMode != networkModeNone { errors = append(errors, "Insufficient trust level to use `network_mode`") } } if !l.trusted.Volumes { if len(c.Devices) != 0 { errors = append(errors, "Insufficient trust level to use `devices`") } if len(c.Volumes.Volumes) != 0 { errors = append(errors, "Insufficient trust level to use `volumes`") } if len(c.Tmpfs) != 0 { errors = append(errors, "Insufficient trust level to use `tmpfs`") } } if len(errors) > 0 { var err error for _, e := range errors { err = multierr.Append(err, newLinterError(e, config.File, yamlPath, false)) } return err } return nil } func (l *Linter) lintSchema(config *WorkflowConfig) error { var linterErr error schemaErrors, err := schema.LintString(config.RawConfig) if err != nil { for _, schemaError := range schemaErrors { linterErr = multierr.Append(linterErr, newLinterError( schemaError.Description(), config.File, schemaError.Field(), true, // TODO: let pipelines fail if the schema is invalid )) } } return linterErr } func (l *Linter) lintDeprecations(config *WorkflowConfig) error { parsed := new(types.Workflow) err := xyaml.Unmarshal([]byte(config.RawConfig), parsed) if err != nil { return err } if len(parsed.RunsOn) > 0 { //nolint:staticcheck err = multierr.Append(err, &pipeline_errors.PipelineError{ Type: pipeline_errors.PipelineErrorTypeDeprecation, IsWarning: true, Message: "Usage of `runs_on` is deprecated, use `when.status`", Data: pipeline_errors.DeprecationErrorData{ File: config.File, Field: fmt.Sprintf("%s.runs_on", config.File), Docs: "https://woodpecker-ci.org/docs/usage/workflow-syntax#status", }, }) } return err } func (l *Linter) lintBadHabits(config *WorkflowConfig) (err error) { parsed := new(types.Workflow) err = xyaml.Unmarshal([]byte(config.RawConfig), parsed) if err != nil { return err } rootEventFilters := len(parsed.When.Constraints) > 0 for _, c := range parsed.When.Constraints { if len(c.Event) == 0 { rootEventFilters = false break } } if !rootEventFilters { // root whens do not necessarily have an event filter, check steps for _, step := range parsed.Steps.ContainerList { var field string var msg string if len(step.When.Constraints) == 0 { field = fmt.Sprintf("steps.%s", step.Name) msg = "Consider adding a `when` block with an `event` filter to this step or the entire workflow" } else { stepEventIndex := -1 for i, c := range step.When.Constraints { if len(c.Event) == 0 { stepEventIndex = i break } } if stepEventIndex > -1 { field = fmt.Sprintf("steps.%s.when[%d]", step.Name, stepEventIndex) msg = "Set an event filter for all steps or the entire workflow on all items of the `when` block" } } if field != "" { err = multierr.Append(err, &pipeline_errors.PipelineError{ Type: pipeline_errors.PipelineErrorTypeBadHabit, Message: msg, Data: pipeline_errors.BadHabitErrorData{ File: config.File, Field: field, Docs: "https://woodpecker-ci.org/docs/usage/linter#event-filter-for-all-steps", }, IsWarning: true, }) } } } return err } ================================================ FILE: pipeline/frontend/yaml/linter/linter_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package linter_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter" ) func TestLint(t *testing.T) { testdatas := []struct{ Title, Data string }{{ Title: "map", Data: ` when: event: push steps: build: image: docker volumes: - /tmp:/tmp commands: - go build - go test publish: image: woodpeckerci/plugin-kaniko settings: repo: foo/bar foo: bar services: redis: image: redis `, }, { Title: "list", Data: ` when: event: push steps: - name: build image: docker volumes: - /tmp:/tmp commands: - go build - go test - name: publish image: woodpeckerci/plugin-kaniko settings: repo: foo/bar foo: bar services: - name: redis image: redis `, }, { Title: "merge maps", Data: ` when: event: push variables: step_template: &base-step image: golang:1.19 commands: - go version steps: test base step: <<: *base-step test base step with latest image: <<: *base-step image: golang:latest `, }, { Title: "explicitly privileged container", Data: "{steps: { build: { image: plugins/docker, privileged: true, settings: { test: 'true' } } }, when: { branch: main, event: push } } }", }} for _, testd := range testdatas { t.Run(testd.Title, func(t *testing.T) { conf, err := yaml.ParseString(testd.Data) assert.NoError(t, err) assert.NoError(t, linter.New(linter.WithTrusted(linter.TrustedConfiguration{ Network: true, Volumes: true, Security: true, })).Lint([]*linter.WorkflowConfig{{ File: testd.Title, RawConfig: testd.Data, Workflow: conf, }}), "expected lint returns no errors") }) } } func TestLintErrors(t *testing.T) { testdata := []struct { from string want string }{ { from: "", want: "Invalid or missing `steps` section", }, { from: "steps: { build: { image: '' } }", want: "Invalid or missing image", }, { from: "steps: { build: { image: golang, privileged: true } }", want: "Insufficient trust level to use `privileged` mode", }, { from: "steps: { build: { image: golang, dns: [ 8.8.8.8 ] } }", want: "Insufficient trust level to use custom `dns`", }, { from: "steps: { build: { image: golang, dns_search: [ example.com ] } }", want: "Insufficient trust level to use `dns_search`", }, { from: "steps: { build: { image: golang, devices: [ '/dev/tty0:/dev/tty0' ] } }", want: "Insufficient trust level to use `devices`", }, { from: "steps: { build: { image: golang, extra_hosts: [ 'somehost:162.242.195.82' ] } }", want: "Insufficient trust level to use `extra_hosts`", }, { from: "steps: { build: { image: golang, network_mode: host } }", want: "Insufficient trust level to use `network_mode`", }, { from: "steps: { build: { image: golang, volumes: [ '/opt/data:/var/lib/mysql' ] } }", want: "Insufficient trust level to use `volumes`", }, { from: "steps: { build: { image: golang, network_mode: 'container:name' } }", want: "Insufficient trust level to use `network_mode`", }, { from: "steps: { build: { image: golang, settings: { test: 'true' }, commands: [ 'echo ja', 'echo nein' ] } }", want: "Cannot configure both `commands` and `settings`", }, { from: "steps: { build: { image: golang, settings: { test: 'true' }, entrypoint: [ '/bin/fish' ] } }", want: "Cannot configure both `entrypoint` and `settings`", }, { from: "steps: { build: { image: golang, settings: { test: 'true' }, environment: { 'TEST': 'true' } } }", want: "Should not configure both `environment` and `settings`", }, { from: "{pipeline: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push } }", want: "Additional property pipeline is not allowed", }, { from: "{steps: { build: { image: plugins/docker, settings: { test: 'true' } } }, when: { branch: main, event: push } } }", want: "The formerly privileged plugin `plugins/docker` is no longer privileged by default, if required, add it to `WOODPECKER_PLUGINS_PRIVILEGED`", }, { from: "{steps: { build: { image: golang, settings: { test: 'true' } } }, when: { branch: main, event: push }, clone: { git: { image: some-other/plugin-git:v1.1.0 } } }", want: "Specified clone image does not match allow list, netrc is not injected", }, { from: "steps: { build: { image: golang }, publish: { image: golang, depends_on: [ binary ] } }", want: "One or more of the specified dependencies do not exist", }, } for _, test := range testdata { conf, err := yaml.ParseString(test.from) require.NoError(t, err) lerr := linter.New().Lint([]*linter.WorkflowConfig{{ File: test.from, RawConfig: test.from, Workflow: conf, }}) assert.Error(t, lerr, "expected lint error for configuration", test.from) lerrors := errors.GetPipelineErrors(lerr) found := false for _, lerr := range lerrors { if lerr.Message == test.want { found = true break } } assert.True(t, found, "Expected error %q, got %q", test.want, lerrors) } } func TestBadHabits(t *testing.T) { testdata := []struct { from string want string }{ { from: "steps: { build: { image: golang } }", want: "Consider adding a `when` block with an `event` filter to this step or the entire workflow", }, { from: "when: [{branch: xyz}, {event: push}]\nsteps: { build: { image: golang } }", want: "Consider adding a `when` block with an `event` filter to this step or the entire workflow", }, { from: "steps: { build: { image: golang, when: [{branch: main}] } }", want: "Set an event filter for all steps or the entire workflow on all items of the `when` block", }, } for _, test := range testdata { conf, err := yaml.ParseString(test.from) assert.NoError(t, err) lerr := linter.New().Lint([]*linter.WorkflowConfig{{ File: test.from, RawConfig: test.from, Workflow: conf, }}) assert.Error(t, lerr, "expected lint error for configuration", test.from) lerrors := errors.GetPipelineErrors(lerr) found := false for _, lerr := range lerrors { if lerr.Message == test.want { found = true break } } assert.True(t, found, "Expected error %q, got %q", test.want, lerrors) } } ================================================ FILE: pipeline/frontend/yaml/linter/option.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package linter // Option configures a linting option. type Option func(*Linter) // WithTrusted adds the trusted option to the linter. func WithTrusted(trusted TrustedConfiguration) Option { return func(linter *Linter) { linter.trusted = trusted } } // PrivilegedPlugins adds the list of privileged plugins. func PrivilegedPlugins(plugins []string) Option { return func(linter *Linter) { linter.privilegedPlugins = &plugins } } // WithTrustedClonePlugins adds the list of trusted clone plugins. func WithTrustedClonePlugins(plugins []string) Option { return func(linter *Linter) { linter.trustedClonePlugins = &plugins } } ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-array-syntax.yaml ================================================ clone: - name: git image: woodpeckerci/plugin-git settings: partial: true - name: testdata image: woodpeckerci/plugin-git settings: remote: https://gitserver/owner/testdata.git path: testdata steps: - name: build image: golang commands: - go build - go test services: - name: database image: mysql - name: cache image: redis ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-backend-options.yaml ================================================ steps: - name: Build Container image: woodpeckerci/plugin-kaniko:1.2.1 backend_options: kubernetes: secrets: - name: aws-secret key: credentials target: file: /root/.aws/credentials ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-broken-plugin.yaml ================================================ steps: publish: image: plugins/docker settings: repo: foo/bar tags: latest environment: CGO: 0 ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-broken-plugin2.yaml ================================================ steps: publish: image: plugins/docker settings: repo: foo/bar tags: latest commands: - env ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-broken.yaml ================================================ branches: main matri: GO_VERSION: - 1.14 - 1.13 steps: test: image: golang:${GO_VERSION} commands: - echo "test ${DATABAS}" build: commands: go build ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-clone-skip.yaml ================================================ steps: test: image: alpine commands: - echo "test" skip_clone: true ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-clone.yaml ================================================ clone: git: image: plugins/git:next depth: 50 path: bitbucket.org/foo/bar recursive: true submodule_override: my-module: https://github.com/octocat/my-module.git steps: test: image: alpine commands: - echo "test" ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-custom-backend.yaml ================================================ steps: build: image: golang commands: - go build - go test backend_options: custom_backend: option1: xyz option2: [1, 2, 3] ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-dag.yaml ================================================ steps: first: image: test second: depends_on: first image: test next: image: test depends_on: - first - second some: image: test depends_on: - first last: image: test depends_on: next ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-kubernetes-backend-tolerations.yaml ================================================ steps: build: image: golang commands: - go build - go test backend_options: kubernetes: tolerations: - key: 'partial-object' operator: 'Equal' value: 'pipeline' effect: 'NoSchedule' - key: 'complete-object' operator: 'Equal' value: 'pipeline' effect: 'NoSchedule' tolerationSeconds: 10 ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-labels.yaml ================================================ labels: location: europe weather: sun hostname: '' steps: build: image: golang:latest commands: - go test ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-matrix.yaml ================================================ steps: test: image: golang:${GO_VERSION} commands: - echo "test ${DATABASE}" matrix: GO_VERSION: - 1.4 - 1.3 DATABASE: - mysql:5.5 - mysql:6.5 - mariadb:10.1 ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-merge-map-and-sequence.yaml ================================================ variables: step_template: &base-step image: golang:1.19 commands: &base-cmds - go version - whoami steps: test-base-step: <<: *base-step test base step with latest image: <<: *base-step image: golang:latest test list overwrite: <<: *base-step commands: - <<: *base-cmds - hostname ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-multi.yaml ================================================ steps: deploy: image: golang commands: - go test depends_on: - lint - build - test ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-pipeline-when.yaml ================================================ when: - branch: [main, deploy] event: push path: - 'folder/**' - '**/*.c' - tag: 'v**' event: tag - event: cron cron: include: - hello - event: exclude: pull_request_closed evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' - event: exclude: pull_request_metadata evaluate: 'CI_COMMIT_AUTHOR == "woodpecker-ci"' steps: echo: image: alpine commands: - echo "test" ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-plugin.yaml ================================================ steps: build: image: golang commands: - go build - go test publish: image: plugins/docker settings: repo: foo/bar tags: latest notify: image: plugins/slack settings: channel: dev ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-run-on.yaml ================================================ steps: build: image: golang commands: - go test runs_on: [success, failure] ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-service.yaml ================================================ steps: build: image: golang commands: - go build - go test services: database: image: mysql ports: - 3306 entrypoint: ['entrypoint.sh'] environment: MYSQL_DATABASE: test MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' cache: image: redis failure: ignore directory: /tmp/ ports: - '6379' ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-step.yaml ================================================ steps: image: image: golang commands: - go test image-pull: image: golang pull: true commands: - go test single-command: image: golang commands: go test entrypoint: image: alpine entrypoint: ['some_entry', '--some-flag'] single-entrypoint: image: alpine entrypoint: some_entry commands: privileged: true image: golang commands: - go get - go test environment: image: golang environment: CGO: 0 GOOS: linux GOARCH: amd64 commands: - go test detached: image: redis detach: true volume: image: docker commands: - docker build --rm -t octocat/hello-world . volumes: - /var/run/docker.sock:/var/run/docker.sock ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-when.yaml ================================================ when: status: [success, failure] steps: when-branch: image: alpine commands: - echo "test" when: branch: main when-branch-array: image: alpine commands: - echo "test" when: branch: [main, deploy] when-event: image: alpine commands: - echo "test" when: event: push branch: include: main exclude: [develop, feature/*] when-event-array: image: alpine commands: - echo "test" when: event: - manual - push - pull_request - pull_request_closed - pull_request_metadata - tag - deployment - release when-ref: image: alpine commands: - echo "test" when: - ref: 'refs/tags/v**' - ref: include: 'refs/tags/v**' exclude: 'refs/tags/v1.**' when-status: image: alpine commands: - echo "test" when: - status: [success, failure] - status: failure when-platform: image: alpine commands: - echo "test" when: platform: linux/amd64 when-platform-array: image: alpine commands: - echo "test" when: platform: [linux/*, windows/amd64] when-environment: image: alpine commands: - echo "test" when: event: deployment when-matrix: image: alpine commands: - echo "test" when: matrix: GO_VERSION: 1.5 REDIS_VERSION: 2.8 when-instance: image: alpine commands: - echo "test" when: instance: stage.woodpecker.company.com when-path: image: alpine commands: - echo "test" when: path: 'folder/**' when-path-array: image: alpine commands: - echo "test" when: path: - 'folder/**' - '**/*.c' when-path-include-exclude: image: alpine commands: - echo "test" when: path: include: ['.woodpecker/*.yml', '*.ini'] exclude: ['*.md', 'docs/**'] ignore_message: '[ALL]' on_empty: true when-repo: image: alpine commands: - echo "test" when: repo: test/test when-multi: image: alpine commands: - echo "test" when: - event: pull_request repo: test/test - event: push branch: main when-cron: image: alpine commands: - echo "test" when: cron: 'update locales' event: cron when-cron-list: image: alpine commands: echo "test" when: - event: cron cron: include: - test - hello exclude: hi when-evaluate: image: alpine commands: echo "test" when: - event: push evaluate: 'CI_PIPELINE_EVENT == "push" && CI_REPO == "owner/repo"' ================================================ FILE: pipeline/frontend/yaml/linter/schema/.woodpecker/test-workspace.yaml ================================================ workspace: base: /go path: src/github.com/octocat/hello-world steps: build: image: golang:latest commands: - go test ================================================ FILE: pipeline/frontend/yaml/linter/schema/schema.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package schema import ( "bytes" _ "embed" "fmt" "io" "codeberg.org/6543/go-yaml2json" "codeberg.org/6543/xyaml" "github.com/xeipuuv/gojsonschema" "gopkg.in/yaml.v3" ) //go:embed schema.json var schemaDefinition []byte // Lint lints an io.Reader against the Woodpecker `schema.json`. func Lint(r io.Reader) ([]gojsonschema.ResultError, error) { schemaLoader := gojsonschema.NewBytesLoader(schemaDefinition) // read yaml config rBytes, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("failed to load yml file %w", err) } // resolve sequence merges yamlDoc := new(yaml.Node) if err := xyaml.Unmarshal(rBytes, yamlDoc); err != nil { return nil, fmt.Errorf("failed to parse yml file %w", err) } // convert to json jsonDoc, err := yaml2json.ConvertNode(yamlDoc) if err != nil { return nil, fmt.Errorf("failed to convert yaml %w", err) } documentLoader := gojsonschema.NewBytesLoader(jsonDoc) result, err := gojsonschema.Validate(schemaLoader, documentLoader) if err != nil { return nil, fmt.Errorf("validation failed %w", err) } if !result.Valid() { return result.Errors(), fmt.Errorf("config not valid") } return nil, nil } func LintString(s string) ([]gojsonschema.ResultError, error) { return Lint(bytes.NewBufferString(s)) } ================================================ FILE: pipeline/frontend/yaml/linter/schema/schema.json ================================================ { "title": "Woodpecker CI configuration file", "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/woodpecker-ci/woodpecker/main/pipeline/frontend/yaml/linter/schema/schema.json", "description": "Schema of a Woodpecker pipeline file. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax", "type": "object", "required": ["steps"], "additionalProperties": false, "properties": { "$schema": { "type": "string", "format": "uri" }, "variables": { "description": "Use yaml aliases to define variables. Read more: https://woodpecker-ci.org/docs/usage/advanced-usage" }, "clone": { "$ref": "#/definitions/clone" }, "skip_clone": { "type": "boolean" }, "when": { "$ref": "#/definitions/workflow_when" }, "steps": { "$ref": "#/definitions/step_list" }, "services": { "$ref": "#/definitions/services" }, "workspace": { "$ref": "#/definitions/workspace" }, "matrix": { "$ref": "#/definitions/matrix" }, "labels": { "$ref": "#/definitions/labels" }, "depends_on": { "type": "array", "minLength": 1, "items": { "type": "string" } }, "runs_on": { "type": "array", "description": "Deprecated: use `when.status` instead. Read more: https://woodpecker-ci.org/docs/usage/workflows#flow-control", "minLength": 1, "items": { "type": "string" } } }, "definitions": { "string_or_string_slice": { "oneOf": [ { "type": "array", "minLength": 1, "items": { "type": "string" } }, { "type": "string" } ] }, "clone": { "description": "Configures the clone step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#clone", "oneOf": [ { "type": "object", "additionalProperties": false, "properties": { "git": { "type": "object", "properties": { "image": { "$ref": "#/definitions/step_image" }, "settings": { "$ref": "#/definitions/clone_settings" } } } } }, { "type": "array", "items": { "$ref": "#/definitions/step" }, "minLength": 1 } ] }, "clone_settings": { "description": "Change the settings of your clone plugin. Read more: https://woodpecker-ci.org/plugins/git-clone", "type": "object", "properties": { "depth": { "type": "number", "description": "If specified, uses git's --depth option to create a shallow clone with a limited number of commits, overwritten by partial" }, "recursive": { "type": "boolean", "default": false, "description": "Clones submodules recursively" }, "partial": { "type": "boolean", "description": "Only fetch the one commit and it's blob objects to resolve all files, overwrite depth with 1" }, "lfs": { "type": "boolean", "default": true, "description": "Set this to false to disable retrieval of LFS files" }, "tags": { "type": "boolean", "description": "Fetches tags when set to true, default is false if event is not tag else true" } }, "additionalProperties": { "type": ["boolean", "string", "number", "array", "object"] } }, "step_list": { "description": "The steps section defines a list of steps which will be executed serially, in the order in which they are defined. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#steps", "oneOf": [ { "type": "object", "additionalProperties": { "$ref": "#/definitions/step" }, "minProperties": 1 }, { "type": "array", "items": { "$ref": "#/definitions/step" }, "minLength": 1 } ] }, "workflow_when": { "description": "Whole workflow can be skipped based on conditions. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#when---global-workflow-conditions", "oneOf": [ { "type": "array", "minLength": 1, "items": { "$ref": "#/definitions/workflow_when_condition" } }, { "$ref": "#/definitions/workflow_when_condition" } ] }, "workflow_when_condition": { "type": "object", "additionalProperties": false, "properties": { "repo": { "description": "Execute a step only on a specific repository. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#repo", "$ref": "#/definitions/constraint_list" }, "branch": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#branch", "$ref": "#/definitions/constraint_list" }, "event": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#event", "default": [], "$ref": "#/definitions/event_constraint_list" }, "ref": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#ref", "$ref": "#/definitions/constraint_list" }, "cron": { "description": "filter cron by title. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#cron", "$ref": "#/definitions/constraint_list" }, "status": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflows#flow-control", "oneOf": [ { "type": "array", "minLength": 1, "items": { "type": "string", "enum": ["success", "failure"] } }, { "type": "string", "enum": ["success", "failure"] } ] }, "platform": { "description": "Execute a step only on a specific platform. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#platform", "$ref": "#/definitions/constraint_list" }, "instance": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#instance", "$ref": "#/definitions/constraint_list" }, "path": { "description": "Execute a step only on commit with certain files added/removed/modified. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#path", "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } }, { "type": "object", "properties": { "include": { "type": "array", "items": { "type": "string" } }, "exclude": { "type": "array", "items": { "type": "string" } }, "ignore_message": { "type": "string" }, "on_empty": { "type": "boolean" } }, "additionalProperties": false } ] }, "evaluate": { "description": "Execute a step only if the expression evaluates to true. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#evaluate", "type": "string" } } }, "step": { "description": "A step of your workflow executes either arbitrary commands or uses a plugin. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#steps", "anyOf": [ { "$ref": "#/definitions/commands_step" }, { "$ref": "#/definitions/plugin_step" } ] }, "commands_step": { "description": "Every step of your pipeline executes arbitrary commands inside a specified docker container. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#steps", "type": "object", "additionalProperties": false, "required": ["image"], "allOf": [ { "if": { "properties": { "detach": { "const": true } } }, "then": {}, "else": { "anyOf": [ { "required": ["commands"] }, { "required": ["entrypoint"] } ] } } ], "properties": { "name": { "description": "The name of the step. Can be used if using the array style steps list.", "type": "string" }, "image": { "$ref": "#/definitions/step_image" }, "privileged": { "$ref": "#/definitions/step_privileged" }, "pull": { "$ref": "#/definitions/step_pull" }, "commands": { "$ref": "#/definitions/step_commands" }, "environment": { "$ref": "#/definitions/step_environment" }, "directory": { "$ref": "#/definitions/step_directory" }, "when": { "$ref": "#/definitions/step_when" }, "volumes": { "$ref": "#/definitions/step_volumes" }, "depends_on": { "description": "Execute a step after another step has finished.", "$ref": "#/definitions/string_or_string_slice" }, "detach": { "description": "Detach a step to run in background until pipeline finishes. Read more: https://woodpecker-ci.org/docs/usage/services#detachment", "type": "boolean" }, "failure": { "description": "How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#failure", "type": "string", "enum": ["fail", "ignore", "cancel"], "default": "fail" }, "backend_options": { "$ref": "#/definitions/step_backend_options" }, "entrypoint": { "$ref": "#/definitions/step_entrypoint" }, "dns": { "description": "Change DNS server for step. Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#dns", "$ref": "#/definitions/string_or_string_slice" }, "dns_search": { "description": "Change DNS lookup domain for step. Only allowed if 'Trusted Network' option is enabled in repo settings by an admin. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#dns", "$ref": "#/definitions/string_or_string_slice" } } }, "plugin_step": { "description": "Plugins let you execute predefined functions in a more secure context. Read more: https://woodpecker-ci.org/docs/usage/plugins/overview", "type": "object", "additionalProperties": false, "required": ["image"], "properties": { "name": { "description": "The name of the step. Can be used if using the array style steps list.", "type": "string" }, "image": { "$ref": "#/definitions/step_image" }, "privileged": { "$ref": "#/definitions/step_privileged" }, "pull": { "$ref": "#/definitions/step_pull" }, "directory": { "$ref": "#/definitions/step_directory" }, "settings": { "$ref": "#/definitions/step_settings" }, "when": { "$ref": "#/definitions/step_when" }, "volumes": { "$ref": "#/definitions/step_volumes" }, "depends_on": { "description": "Execute a step after another step has finished.", "$ref": "#/definitions/string_or_string_slice" }, "detach": { "description": "Detach a step to run in background until pipeline finishes. Read more: https://woodpecker-ci.org/docs/usage/services#detachment", "type": "boolean" }, "failure": { "description": "How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#failure", "type": "string", "enum": ["fail", "ignore"], "default": "fail" }, "backend_options": { "$ref": "#/definitions/step_backend_options" } } }, "step_when": { "description": "Steps can be skipped based on conditions. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#when---conditional-execution", "oneOf": [ { "type": "array", "minLength": 1, "items": { "$ref": "#/definitions/step_when_condition" } }, { "$ref": "#/definitions/step_when_condition" } ] }, "step_when_condition": { "type": "object", "additionalProperties": false, "properties": { "repo": { "description": "Execute a step only on a specific repository. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#repo", "$ref": "#/definitions/constraint_list" }, "branch": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#branch", "$ref": "#/definitions/constraint_list" }, "event": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#event", "$ref": "#/definitions/event_constraint_list" }, "ref": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#ref", "$ref": "#/definitions/constraint_list" }, "cron": { "description": "filter cron by title. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#cron", "$ref": "#/definitions/constraint_list" }, "status": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#status", "oneOf": [ { "type": "array", "minLength": 1, "items": { "type": "string", "enum": ["success", "failure"] } }, { "type": "string", "enum": ["success", "failure"] } ] }, "platform": { "description": "Execute a step only on a specific platform. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#platform", "$ref": "#/definitions/constraint_list" }, "matrix": { "description": "Read more: https://woodpecker-ci.org/docs/usage/matrix-workflows", "type": "object", "additionalProperties": { "type": ["boolean", "string", "number"] } }, "instance": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#instance", "$ref": "#/definitions/constraint_list" }, "path": { "description": "Execute a step only on commit with certain files added/removed/modified. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#path", "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } }, { "type": "object", "properties": { "include": { "type": "array", "items": { "type": "string" } }, "exclude": { "type": "array", "items": { "type": "string" } }, "ignore_message": { "type": "string" }, "on_empty": { "type": "boolean" } }, "additionalProperties": false } ] }, "evaluate": { "description": "Execute a step only if the expression evaluates to true. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#evaluate", "type": "string" } } }, "event_enum": { "enum": [ "push", "pull_request", "pull_request_closed", "pull_request_metadata", "tag", "deployment", "cron", "manual", "release" ] }, "event_constraint_list": { "oneOf": [ { "$ref": "#/definitions/event_enum" }, { "type": "array", "minLength": 1, "items": { "$ref": "#/definitions/event_enum" } } ] }, "constraint_list": { "oneOf": [ { "type": "string" }, { "type": "array", "minLength": 1, "items": { "type": "string" } }, { "type": "object", "additionalProperties": false, "properties": { "include": { "oneOf": [ { "type": "string" }, { "type": "array", "minLength": 1, "items": { "type": "string" } } ] }, "exclude": { "oneOf": [ { "type": "string" }, { "type": "array", "minLength": 1, "items": { "type": "string" } } ] } } } ] }, "step_image": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#image", "type": "string" }, "step_privileged": { "description": "Run the step in privileged mode. Read more: https://woodpecker-ci.org/docs/next/usage/workflow-syntax#privileged-mode", "type": "boolean", "default": false }, "step_pull": { "description": "Always pull the latest image on pipeline execution Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#image", "type": "boolean" }, "step_commands": { "description": "Commands of every pipeline step are executed serially as if you would enter them into your local shell. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#commands", "oneOf": [ { "type": "array", "items": { "type": "string" }, "minLength": 1 }, { "type": "string" } ] }, "step_environment": { "description": "Pass environment variables to a pipeline step. Read more: https://woodpecker-ci.org/docs/usage/environment", "type": "object", "additionalProperties": { "type": ["boolean", "string", "number", "array", "object"] } }, "step_entrypoint": { "description": "Defines container entrypoint.", "$ref": "#/definitions/string_or_string_slice" }, "step_settings": { "description": "Change the settings of your plugin. Read more: https://woodpecker-ci.org/docs/usage/plugins/overview", "type": "object", "additionalProperties": { "type": ["boolean", "string", "number", "array", "object"] } }, "step_volumes": { "description": "Mount files or folders from the host machine into your step container. Read more: https://woodpecker-ci.org/docs/usage/volumes", "type": "array", "items": { "type": "string" }, "minLength": 1 }, "step_directory": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#directory", "type": "string" }, "step_backend_options": { "description": "Advanced options for the different agent backends", "type": "object", "properties": { "kubernetes": { "$ref": "#/definitions/step_backend_kubernetes" } } }, "step_backend_kubernetes": { "description": "Advanced options for the kubernetes agent backends", "type": "object", "properties": { "labels": { "type": "object", "additionalProperties": { "type": ["boolean", "string", "number"] } }, "annotations": { "type": "object", "additionalProperties": { "type": ["boolean", "string", "number"] } }, "tolerations": { "description": "The tolerations section defines a list of references to the native Kubernetes tolerations. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#tolerations", "type": "array", "items": { "$ref": "#/definitions/step_backend_kubernetes_toleration_object" }, "minLength": 1 }, "securityContext": { "$ref": "#/definitions/step_backend_kubernetes_security_context" }, "runtimeClassName": { "description": "Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#runtimeclassname", "type": "string" }, "secrets": { "description": "The secrets section defines a list of references to the native Kubernetes secrets", "type": "array", "items": { "$ref": "#/definitions/step_kubernetes_secret" }, "minLength": 1 } } }, "step_backend_kubernetes_resources": { "description": "Resources for the kubernetes backend. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#step-specific-configuration", "type": "object", "properties": { "requests": { "$ref": "#/definitions/step_kubernetes_resources_object" }, "limits": { "$ref": "#/definitions/step_kubernetes_resources_object" } } }, "step_backend_kubernetes_security_context": { "description": "Pods / containers security context. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#security-context", "type": "object", "properties": { "privileged": { "type": "boolean" }, "runAsNonRoot": { "type": "boolean" }, "runAsUser": { "type": "number" }, "runAsGroup": { "type": "number" }, "fsGroup": { "type": "number" }, "seccompProfile": { "$ref": "#/definitions/step_backend_kubernetes_secprofile" }, "apparmorProfile": { "$ref": "#/definitions/step_backend_kubernetes_secprofile" } } }, "step_backend_kubernetes_secprofile": { "description": "Pods / containers security profile. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#step-specific-configuration", "type": "object", "properties": { "type": { "type": "string" }, "localhostProfile": { "type": "string" } } }, "step_kubernetes_resources_object": { "description": "A list of kubernetes resource mappings", "type": "object", "additionalProperties": { "type": "string" } }, "step_backend_kubernetes_service_account": { "description": "serviceAccountName to be use by job. Read more: https://woodpecker-ci.org/docs/administration/configuration/backends/kubernetes#service-account", "type": "object", "properties": { "requests": { "$ref": "#/definitions/step_kubernetes_service_account_object" }, "limits": { "$ref": "#/definitions/step_kubernetes_service_account_object" } } }, "step_kubernetes_service_account_object": { "description": "A list of kubernetes resource mappings", "type": "object", "additionalProperties": { "type": "string" } }, "step_kubernetes_secret": { "description": "A reference to a native Kubernetes secret", "type": "object", "additionalProperties": false, "properties": { "name": { "description": "The name of the secret. Can be used if using the array style secrets list.", "type": "string" }, "key": { "description": "The key of the secret to select from.", "type": "string" }, "target": { "$ref": "#/definitions/step_kubernetes_secret_target" } } }, "step_kubernetes_secret_target": { "description": "A target which a native Kubernetes secret maps to.", "oneOf": [ { "type": "object", "additionalProperties": false, "properties": { "env": { "description": "The name of the environment variable which secret maps to.", "type": "string" } } }, { "type": "object", "additionalProperties": false, "properties": { "file": { "description": "The filename (path) which secret maps to.", "type": "string" } } } ] }, "step_backend_kubernetes_toleration_object": { "description": "Toleration entry for the kubernetes backend.", "type": "object", "properties": { "key": { "type": "string" }, "operator": { "type": "string" }, "value": { "type": "string" }, "effect": { "type": "string" }, "tolerationSeconds": { "type": "integer" } } }, "services": { "description": "Read more: https://woodpecker-ci.org/docs/usage/services", "oneOf": [ { "type": "object", "additionalProperties": { "$ref": "#/definitions/service" }, "minProperties": 1 }, { "type": "array", "items": { "$ref": "#/definitions/service" }, "minLength": 1 } ] }, "service": { "description": "Read more: https://woodpecker-ci.org/docs/usage/services", "type": "object", "additionalProperties": false, "minProperties": 1, "required": ["image"], "properties": { "name": { "description": "The name of the service. Can be used if using the array style services list", "type": "string" }, "image": { "$ref": "#/definitions/step_image" }, "privileged": { "$ref": "#/definitions/step_privileged" }, "pull": { "$ref": "#/definitions/step_pull" }, "commands": { "$ref": "#/definitions/step_commands" }, "environment": { "$ref": "#/definitions/step_environment" }, "entrypoint": { "$ref": "#/definitions/step_entrypoint" }, "directory": { "$ref": "#/definitions/step_directory" }, "settings": { "$ref": "#/definitions/step_settings" }, "when": { "$ref": "#/definitions/step_when" }, "volumes": { "$ref": "#/definitions/step_volumes" }, "failure": { "description": "How to handle the failure of this step. Read more: https://woodpecker-ci.org/docs/usage/services#stopping", "type": "string", "enum": ["fail", "ignore"], "default": "fail" }, "backend_options": { "$ref": "#/definitions/step_backend_options" }, "ports": { "description": "expose ports to which other steps can connect to", "type": "array", "items": { "oneOf": [ { "type": "number" }, { "type": "string" } ] }, "minLength": 1 } } }, "workspace": { "description": "Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#workspace", "type": "object", "additionalProperties": true }, "matrix": { "description": "Execute pipeline for each matrix combination. Read more: https://woodpecker-ci.org/docs/usage/matrix-workflows", "type": "object", "properties": { "include": { "type": "array", "items": { "type": "object" }, "minLength": 1 } }, "additionalProperties": { "type": "array", "items": { "type": ["boolean", "string", "number"] }, "minLength": 1 } }, "labels": { "description": "Configures the labels used for the agent selection. Read more: https://woodpecker-ci.org/docs/usage/workflow-syntax#labels", "type": "object", "additionalProperties": { "type": ["boolean", "string", "number"] } } } } ================================================ FILE: pipeline/frontend/yaml/linter/schema/schema_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package schema_test import ( "fmt" "io" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter/schema" ) func TestSchema(t *testing.T) { t.Parallel() testTable := []struct { name string testFile string fail bool }{ { name: "Clone", testFile: ".woodpecker/test-clone.yaml", }, { name: "Clone skip", testFile: ".woodpecker/test-clone-skip.yaml", }, { name: "Matrix", testFile: ".woodpecker/test-matrix.yaml", }, { name: "Multi Pipeline", testFile: ".woodpecker/test-multi.yaml", }, { name: "Plugin", testFile: ".woodpecker/test-plugin.yaml", }, { name: "Run on", testFile: ".woodpecker/test-run-on.yaml", }, { name: "Service", testFile: ".woodpecker/test-service.yaml", }, { name: "Step", testFile: ".woodpecker/test-step.yaml", }, { name: "When", testFile: ".woodpecker/test-when.yaml", }, { name: "Workspace", testFile: ".woodpecker/test-workspace.yaml", }, { name: "Labels", testFile: ".woodpecker/test-labels.yaml", }, { name: "Map and Sequence Merge", // https://woodpecker-ci.org/docs/next/usage/advanced-yaml-syntax testFile: ".woodpecker/test-merge-map-and-sequence.yaml", }, { name: "Backend options", testFile: ".woodpecker/test-backend-options.yaml", }, { name: "Broken Config", testFile: ".woodpecker/test-broken.yaml", fail: true, }, { name: "Array syntax", testFile: ".woodpecker/test-array-syntax.yaml", fail: false, }, { name: "Step DAG syntax", testFile: ".woodpecker/test-dag.yaml", fail: false, }, { name: "Custom backend", testFile: ".woodpecker/test-custom-backend.yaml", fail: false, }, { name: "Broken Plugin by environment", testFile: ".woodpecker/test-broken-plugin.yaml", fail: true, }, { name: "Broken Plugin by commands", testFile: ".woodpecker/test-broken-plugin2.yaml", fail: true, }, { name: "Kubernetes backend tolerations", testFile: ".woodpecker/test-kubernetes-backend-tolerations.yaml", fail: false, }, } for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { fi, err := os.Open(tt.testFile) assert.NoError(t, err, "could not open test file") defer fi.Close() configErrors, err := schema.Lint(fi) if tt.fail { if len(configErrors) == 0 { assert.Error(t, err, "Expected config errors but got none") } } else { assert.NoError(t, err, fmt.Sprintf("Validation failed: %v", configErrors)) t.Run("parse", func(t *testing.T) { config, err := io.ReadAll(fi) require.NoError(t, err) parsedConfig, err := yaml.ParseBytes(config) assert.NoError(t, err, "if schema lint passes, we should be able to parse it") assert.NotNil(t, parsedConfig) }) } }) } } ================================================ FILE: pipeline/frontend/yaml/matrix/matrix.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package matrix import ( "strings" "codeberg.org/6543/xyaml" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" ) const ( limitTags = 10 limitAxis = 25 ) // Matrix represents the pipeline matrix. type Matrix map[string][]string // Axis represents a single permutation of entries from the pipeline matrix. type Axis map[string]string // String returns a string representation of an Axis as a comma-separated list // of environment variables. func (a Axis) String() string { var envs []string for k, v := range a { envs = append(envs, k+"="+v) } return strings.Join(envs, " ") } // Parse parses the Yaml matrix definition. func Parse(data []byte) ([]Axis, error) { axis, err := parseList(data) if err == nil && len(axis) != 0 { return axis, nil } matrix, err := parse(data) if err != nil { return nil, err } if len(matrix) == 0 { return []Axis{}, nil } return calc(matrix), nil } // ParseString parses the Yaml string matrix definition. func ParseString(data string) ([]Axis, error) { return Parse([]byte(data)) } func calc(matrix Matrix) []Axis { // calculate number of permutations and extract the list of tags // (ie go_version, redis_version, etc) var perm int var tags []string for k, v := range matrix { perm *= len(v) if perm == 0 { perm = len(v) } tags = append(tags, k) } // structure to hold the transformed result set var axisList []Axis // for each axis calculate the unique set of values that should be used. for p := 0; p < perm; p++ { axis := map[string]string{} decrease := perm for i, tag := range tags { elems := matrix[tag] decrease /= len(elems) elem := p / decrease % len(elems) axis[tag] = elems[elem] // enforce a maximum number of tags in the pipeline matrix. if i > limitTags { break } } // append to the list of axis. axisList = append(axisList, axis) // enforce a maximum number of axis that should be calculated. if p > limitAxis { break } } return axisList } func parse(raw []byte) (Matrix, error) { data := struct { Matrix map[string][]string }{} if err := xyaml.Unmarshal(raw, &data); err != nil { return nil, &pipeline_errors.PipelineError{Message: err.Error(), Type: pipeline_errors.PipelineErrorTypeCompiler} } return data.Matrix, nil } func parseList(raw []byte) ([]Axis, error) { data := struct { Matrix struct { Include []Axis } }{} if err := xyaml.Unmarshal(raw, &data); err != nil { return nil, &pipeline_errors.PipelineError{Message: err.Error(), Type: pipeline_errors.PipelineErrorTypeCompiler} } return data.Matrix.Include, nil } ================================================ FILE: pipeline/frontend/yaml/matrix/matrix_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package matrix import ( "testing" "github.com/stretchr/testify/assert" ) func TestMatrix(t *testing.T) { axis, _ := ParseString(fakeMatrix) assert.Len(t, axis, 24) set := map[string]bool{} for _, perm := range axis { set[perm.String()] = true } assert.Len(t, set, 24) } func TestMatrixEmpty(t *testing.T) { axis, err := ParseString("") assert.NoError(t, err) assert.Empty(t, axis) } func TestMatrixIncluded(t *testing.T) { axis, err := ParseString(fakeMatrixInclude) assert.NoError(t, err) assert.Len(t, axis, 2) assert.Equal(t, "1.5", axis[0]["go_version"]) assert.Equal(t, "1.6", axis[1]["go_version"]) assert.Equal(t, "3.4", axis[0]["python_version"]) assert.Equal(t, "3.4", axis[1]["python_version"]) } var fakeMatrix = ` matrix: go_version: - go1 - go1.2 python_version: - 3.2 - 3.3 django_version: - 1.7 - 1.7.1 - 1.7.2 redis_version: - 2.6 - 2.8 ` var fakeMatrixInclude = ` matrix: include: - go_version: 1.5 python_version: 3.4 - go_version: 1.6 python_version: 3.4 ` ================================================ FILE: pipeline/frontend/yaml/parse.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package yaml import ( "codeberg.org/6543/xyaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types" ) // ParseBytes parses the configuration from bytes b. func ParseBytes(b []byte) (*types.Workflow, error) { out := new(types.Workflow) err := xyaml.Unmarshal(b, out) if err != nil { return nil, err } return out, nil } // ParseString parses the configuration from string s. func ParseString(s string) (*types.Workflow, error) { return ParseBytes( []byte(s), ) } ================================================ FILE: pipeline/frontend/yaml/parse_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package yaml import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" yaml_base_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" ) func TestParse(t *testing.T) { t.Run("Should unmarshal a string", func(t *testing.T) { out, err := ParseString(sampleYaml) assert.NoError(t, err) assert.Contains(t, out.When.Constraints[0].Event, "tester") assert.Equal(t, "/go", out.Workspace.Base) assert.Equal(t, "src/github.com/octocat/hello-world", out.Workspace.Path) assert.Equal(t, "database", out.Services.ContainerList[0].Name) assert.Equal(t, "mysql", out.Services.ContainerList[0].Image) assert.Equal(t, "test", out.Steps.ContainerList[0].Name) assert.Equal(t, "golang", out.Steps.ContainerList[0].Image) assert.Equal(t, yaml_base_types.StringOrSlice{"go install", "go test"}, out.Steps.ContainerList[0].Commands) assert.Equal(t, "build", out.Steps.ContainerList[1].Name) assert.Equal(t, "golang", out.Steps.ContainerList[1].Image) assert.Equal(t, yaml_base_types.StringOrSlice{"go build"}, out.Steps.ContainerList[1].Commands) assert.Equal(t, "notify", out.Steps.ContainerList[2].Name) assert.Equal(t, "slack", out.Steps.ContainerList[2].Image) assert.Equal(t, "frontend", out.Labels["com.example.team"]) assert.Equal(t, "build", out.Labels["com.example.type"]) assert.Equal(t, "lint", out.DependsOn[0]) assert.Equal(t, "test", out.DependsOn[1]) assert.EqualValues(t, []string{"success", "failure"}, out.When.Constraints[0].Status) assert.False(t, out.SkipClone) }) t.Run("Should fail on invalid yaml", func(t *testing.T) { _, err := ParseString("notvalid") assert.Error(t, err) }) t.Run("Should handle simple yaml anchors", func(t *testing.T) { out, err := ParseString(simpleYamlAnchors) assert.NoError(t, err) assert.Equal(t, "notify_success", out.Steps.ContainerList[0].Name) assert.Equal(t, "plugins/slack", out.Steps.ContainerList[0].Image) }) t.Run("Should unmarshal variables", func(t *testing.T) { out, err := ParseString(sampleVarYaml) assert.NoError(t, err) assert.Equal(t, "notify_fail", out.Steps.ContainerList[0].Name) assert.Equal(t, "plugins/slack", out.Steps.ContainerList[0].Image) assert.Equal(t, "notify_success", out.Steps.ContainerList[1].Name) assert.Equal(t, "plugins/slack", out.Steps.ContainerList[1].Image) assert.Empty(t, out.Steps.ContainerList[0].When.Constraints) assert.Equal(t, "notify_success", out.Steps.ContainerList[1].Name) assert.Equal(t, "plugins/slack", out.Steps.ContainerList[1].Image) assert.Equal(t, yaml_base_types.StringOrSlice{"push"}, out.Steps.ContainerList[1].When.Constraints[0].Event) }) } func TestMatch(t *testing.T) { matchConfig, err := ParseString(sampleYaml) assert.NoError(t, err) t.Run("Should match event tester", func(t *testing.T) { match, err := matchConfig.When.Match(metadata.Metadata{ Curr: metadata.Pipeline{ Event: "tester", }, }, false, nil) assert.True(t, match) assert.NoError(t, err) }) t.Run("Should match event tester2", func(t *testing.T) { match, err := matchConfig.When.Match(metadata.Metadata{ Curr: metadata.Pipeline{ Event: "tester2", }, }, false, nil) assert.True(t, match) assert.NoError(t, err) }) t.Run("Should match branch tester", func(t *testing.T) { match, err := matchConfig.When.Match(metadata.Metadata{ Curr: metadata.Pipeline{ Commit: metadata.Commit{ Branch: "tester", }, }, }, true, nil) assert.True(t, match) assert.NoError(t, err) }) t.Run("Should not match event push", func(t *testing.T) { match, err := matchConfig.When.Match(metadata.Metadata{ Curr: metadata.Pipeline{ Event: "push", }, }, false, nil) assert.False(t, match) assert.NoError(t, err) }) } func TestParseLegacy(t *testing.T) { sampleYamlPipeline := ` labels: platform: linux/amd64 steps: say hello: image: bash commands: echo hello ` sampleYamlPipelineLegacyIgnore := ` platform: windows/amd64 labels: platform: linux/amd64 steps: say hello: image: bash commands: echo hello pipeline: old crap: image: bash commands: meh! ` workflow1, err := ParseString(sampleYamlPipeline) require.NoError(t, err) workflow2, err := ParseString(sampleYamlPipelineLegacyIgnore) require.NoError(t, err) assert.EqualValues(t, workflow1, workflow2) assert.Len(t, workflow1.Steps.ContainerList, 1) assert.EqualValues(t, "say hello", workflow1.Steps.ContainerList[0].Name) } var sampleYaml = ` image: hello-world when: - event: - tester - tester2 status: [ success, failure ] - branch: - tester status: [ success, failure ] workspace: path: src/github.com/octocat/hello-world base: /go steps: test: image: golang commands: - go install - go test build: image: golang commands: - go build when: event: push depends_on: [] notify: image: slack settings: channel: dev when: event: failure services: database: image: mysql labels: com.example.type: "build" com.example.team: "frontend" depends_on: - lint - test ` var simpleYamlAnchors = ` vars: image: &image plugins/slack steps: notify_success: image: *image ` var sampleVarYaml = ` variables: &SLACK image: plugins/slack steps: notify_fail: *SLACK notify_success: << : *SLACK when: event: push echo: when: - path: wow.sh repo: "test" branch: exclude: main - path: - test.yaml - test.zig - path: exclude: a on_empty: true - ref: ref/tags/v1 path: env: image: print environment: DRIVER: next PLATFORM: linux ` func TestReSerialize(t *testing.T) { work1, err := ParseString(sampleVarYaml) require.NoError(t, err) work1Bin, err := yaml.Marshal(work1) require.NoError(t, err) assert.EqualValues(t, `steps: - name: notify_fail image: plugins/slack - name: notify_success image: plugins/slack when: event: push - name: echo when: - repo: test branch: exclude: main path: wow.sh - path: - test.yaml - test.zig - path: exclude: a - ref: ref/tags/v1 - name: env image: print environment: DRIVER: next PLATFORM: linux `, string(work1Bin)) work2, err := ParseString(sampleYaml) require.NoError(t, err) workBin2, err := yaml.Marshal(work2) require.NoError(t, err) // TODO: fix "steps.[1].depends_on: []" to be re-serialized! assert.EqualValues(t, `when: - status: - success - failure event: - tester - tester2 - branch: tester status: - success - failure workspace: base: /go path: src/github.com/octocat/hello-world steps: - name: test image: golang commands: - go install - go test - name: build image: golang commands: go build when: event: push - name: notify image: slack settings: channel: dev when: event: failure services: - name: database image: mysql labels: com.example.team: frontend com.example.type: build depends_on: - lint - test `, string(workBin2)) } func TestSlice(t *testing.T) { out, err := ParseString(sampleYaml) require.NoError(t, err) t.Run("should marshal a not set slice to nil", func(t *testing.T) { assert.Equal(t, "test", out.Steps.ContainerList[0].Name) assert.Nil(t, out.Steps.ContainerList[0].DependsOn) assert.Empty(t, out.Steps.ContainerList[0].DependsOn) }) t.Run("should marshal an empty slice", func(t *testing.T) { assert.Equal(t, "build", out.Steps.ContainerList[1].Name) assert.NotNil(t, out.Steps.ContainerList[1].DependsOn) assert.Empty(t, (out.Steps.ContainerList[1].DependsOn)) }) } ================================================ FILE: pipeline/frontend/yaml/types/base/int.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package base import ( "errors" "strconv" "github.com/docker/go-units" ) // StringOrInt represents a string or an integer. type StringOrInt int64 // UnmarshalYAML implements the Unmarshaler interface. func (s *StringOrInt) UnmarshalYAML(unmarshal func(any) error) error { var intType int64 if err := unmarshal(&intType); err == nil { *s = StringOrInt(intType) return nil } var stringType string if err := unmarshal(&stringType); err == nil { intType, err := strconv.ParseInt(stringType, 10, 64) if err != nil { return err } *s = StringOrInt(intType) return nil } return errors.New("failed to unmarshal StringOrInt") } // MemStringOrInt represents a string or an integer // the String supports notations like 10m for then Megabyte of memory. type MemStringOrInt int64 // UnmarshalYAML implements the Unmarshaler interface. func (s *MemStringOrInt) UnmarshalYAML(unmarshal func(any) error) error { var intType int64 if err := unmarshal(&intType); err == nil { *s = MemStringOrInt(intType) return nil } var stringType string if err := unmarshal(&stringType); err == nil { intType, err := units.RAMInBytes(stringType) if err != nil { return err } *s = MemStringOrInt(intType) return nil } return errors.New("failed to unmarshal MemStringOrInt") } ================================================ FILE: pipeline/frontend/yaml/types/base/int_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package base import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) type StructStringOrInt struct { Foo StringOrInt } func TestStringOrIntYaml(t *testing.T) { for _, str := range []string{`{foo: 10}`, `{foo: "10"}`} { s := StructStringOrInt{} assert.NoError(t, yaml.Unmarshal([]byte(str), &s)) assert.Equal(t, StringOrInt(10), s.Foo) d, err := yaml.Marshal(&s) assert.NoError(t, err) s2 := StructStringOrInt{} assert.NoError(t, yaml.Unmarshal(d, &s2)) assert.Equal(t, StringOrInt(10), s2.Foo) } } ================================================ FILE: pipeline/frontend/yaml/types/base/slice.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package base import ( "errors" "fmt" ) // StringOrSlice represents a string or an array of strings. // We need to override the yaml decoder to accept both options. type StringOrSlice []string // UnmarshalYAML implements the Unmarshaler interface. func (s *StringOrSlice) UnmarshalYAML(unmarshal func(any) error) error { var stringType string if err := unmarshal(&stringType); err == nil { *s = []string{stringType} return nil } var sliceType []any if err := unmarshal(&sliceType); err == nil { parts, err := toStrings(sliceType) if err != nil { return err } *s = parts return nil } return errors.New("failed to unmarshal StringOrSlice") } // MarshalYAML implements custom Yaml marshaling. func (s StringOrSlice) MarshalYAML() (any, error) { if len(s) == 0 { return nil, nil } else if len(s) == 1 { return s[0], nil } return []string(s), nil } func toStrings(s []any) ([]string, error) { if s == nil { return nil, nil } r := make([]string, len(s)) for k, v := range s { if sv, ok := v.(string); ok { r[k] = sv } else { return nil, fmt.Errorf("cannot unmarshal '%v' of type %T into a string value", v, v) } } return r, nil } ================================================ FILE: pipeline/frontend/yaml/types/base/slice_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package base import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) type StructStringOrSlice struct { Foo StringOrSlice `yaml:"foo"` Bar StringOrSlice `yaml:"bar,omitempty"` } func TestStringOrSliceYaml(t *testing.T) { t.Run("unmarshal", func(t *testing.T) { str := `{foo: [bar, baz]}` s := StructStringOrSlice{} assert.NoError(t, yaml.Unmarshal([]byte(str), &s)) assert.Equal(t, StringOrSlice{"bar", "baz"}, s.Foo) d, err := yaml.Marshal(&s) assert.NoError(t, err) s2 := StructStringOrSlice{} assert.NoError(t, yaml.Unmarshal(d, &s2)) assert.Equal(t, StringOrSlice{"bar", "baz"}, s2.Foo) }) t.Run("marshal", func(t *testing.T) { str := StructStringOrSlice{} out, err := yaml.Marshal(str) assert.NoError(t, err) assert.EqualValues(t, "foo: null\n", string(out)) str = StructStringOrSlice{Foo: []string{"a\""}} out, err = yaml.Marshal(str) assert.NoError(t, err) assert.EqualValues(t, "foo: a\"\n", string(out)) str = StructStringOrSlice{Foo: []string{"a", "b", "c"}} out, err = yaml.Marshal(str) assert.NoError(t, err) assert.EqualValues(t, `foo: - a - b - c `, string(out)) }) str := `{foo: [bar, "baz"]}` s := StructStringOrSlice{} assert.NoError(t, yaml.Unmarshal([]byte(str), &s)) assert.Equal(t, StringOrSlice{"bar", "baz"}, s.Foo) d, err := yaml.Marshal(&s) assert.NoError(t, err) s = StructStringOrSlice{} assert.NoError(t, yaml.Unmarshal(d, &s)) assert.Equal(t, StringOrSlice{"bar", "baz"}, s.Foo) str = `{foo: []}` s = StructStringOrSlice{} assert.NoError(t, yaml.Unmarshal([]byte(str), &s)) assert.Equal(t, StringOrSlice{}, s.Foo) str = `{}` s = StructStringOrSlice{} assert.NoError(t, yaml.Unmarshal([]byte(str), &s)) assert.Nil(t, s.Foo) } ================================================ FILE: pipeline/frontend/yaml/types/container.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/utils" ) // Container defines a container. type Container struct { // common Name string `yaml:"name,omitempty"` Image string `yaml:"image,omitempty"` Pull bool `yaml:"pull,omitempty"` Commands base.StringOrSlice `yaml:"commands,omitempty"` Entrypoint base.StringOrSlice `yaml:"entrypoint,omitempty"` Directory string `yaml:"directory,omitempty"` Settings map[string]any `yaml:"settings,omitempty"` Environment map[string]any `yaml:"environment,omitempty"` // flow control DependsOn base.StringOrSlice `yaml:"depends_on,omitempty"` When constraint.When `yaml:"when,omitempty"` Failure string `yaml:"failure,omitempty"` Detached bool `yaml:"detach,omitempty"` // state Volumes Volumes `yaml:"volumes,omitempty"` // network Ports []string `yaml:"ports,omitempty"` DNS base.StringOrSlice `yaml:"dns,omitempty"` DNSSearch base.StringOrSlice `yaml:"dns_search,omitempty"` // backend specific BackendOptions map[string]any `yaml:"backend_options,omitempty"` // ACTIVE DEVELOPMENT BELOW // Docker and Kubernetes Specific Privileged bool `yaml:"privileged,omitempty"` // Undocumented Devices []string `yaml:"devices,omitempty"` ExtraHosts []string `yaml:"extra_hosts,omitempty"` NetworkMode string `yaml:"network_mode,omitempty"` Tmpfs []string `yaml:"tmpfs,omitempty"` } func (c *Container) IsPlugin() bool { return len(c.Commands) == 0 && len(c.Entrypoint) == 0 && len(c.Environment) == 0 } func (c *Container) IsTrustedCloneImage(trustedClonePlugins []string) bool { return c.IsPlugin() && utils.MatchImageDynamic(c.Image, trustedClonePlugins...) } ================================================ FILE: pipeline/frontend/yaml/types/container_list.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "fmt" "gopkg.in/yaml.v3" ) // ContainerList contains ordered collection of containers. type ContainerList struct { ContainerList []*Container } // UnmarshalYAML implements the Unmarshaler interface. func (c *ContainerList) UnmarshalYAML(value *yaml.Node) error { switch value.Kind { // We support maps ... case yaml.MappingNode: c.ContainerList = make([]*Container, 0, len(value.Content)/2+1) // We cannot use decode on specific values // since if we try to load from a map, the order // will not be kept. Therefor use value.Content // and take the map values i%2=1 for i, n := range value.Content { if i%2 == 1 { container := &Container{} if err := n.Decode(container); err != nil { return err } if container.Name == "" { container.Name = fmt.Sprintf("%v", value.Content[i-1].Value) } c.ContainerList = append(c.ContainerList, container) } } // ... and lists case yaml.SequenceNode: c.ContainerList = make([]*Container, 0, len(value.Content)) for i, n := range value.Content { container := &Container{} if err := n.Decode(container); err != nil { return err } if container.Name == "" { container.Name = fmt.Sprintf("step-%d", i) } c.ContainerList = append(c.ContainerList, container) } default: return fmt.Errorf("yaml node type[%d]: '%s' not supported", value.Kind, value.Tag) } return nil } // MarshalYAML implements custom Yaml marshaling. func (c ContainerList) MarshalYAML() (any, error) { return c.ContainerList, nil } ================================================ FILE: pipeline/frontend/yaml/types/container_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types/base" ) var containerYaml = []byte(` image: golang:latest commands: - go build - go test detach: true devices: - /dev/ttyUSB0:/dev/ttyUSB0 directory: example/ dns: 8.8.8.8 dns_search: example.com entrypoint: [/bin/sh, -c] environment: RACK_ENV: development SHOW: true extra_hosts: - somehost:162.242.195.82 - otherhost:50.31.209.229 - ipv6:2001:db8::10 name: my-build-container network_mode: bridge networks: - some-network - other-network pull: true privileged: true volumes: - /var/lib/mysql - /opt/data:/var/lib/mysql - /etc/configs:/etc/configs/:ro tmpfs: - /var/lib/test when: - branch: main - event: cron cron: job1 settings: foo: bar baz: false ports: - 8080 - 4443/tcp - 51820/udp `) func TestUnmarshalContainer(t *testing.T) { want := Container{ Commands: base.StringOrSlice{"go build", "go test"}, Detached: true, Devices: []string{"/dev/ttyUSB0:/dev/ttyUSB0"}, Directory: "example/", DNS: base.StringOrSlice{"8.8.8.8"}, DNSSearch: base.StringOrSlice{"example.com"}, Entrypoint: []string{"/bin/sh", "-c"}, Environment: map[string]any{"RACK_ENV": "development", "SHOW": true}, ExtraHosts: []string{"somehost:162.242.195.82", "otherhost:50.31.209.229", "ipv6:2001:db8::10"}, Image: "golang:latest", Name: "my-build-container", NetworkMode: "bridge", Pull: true, Privileged: true, Tmpfs: base.StringOrSlice{"/var/lib/test"}, Volumes: Volumes{ Volumes: []*Volume{ {Source: "", Destination: "/var/lib/mysql"}, {Source: "/opt/data", Destination: "/var/lib/mysql"}, {Source: "/etc/configs", Destination: "/etc/configs/", AccessMode: "ro"}, }, }, When: constraint.When{ Constraints: []constraint.Constraint{ { Branch: constraint.List{ Include: []string{"main"}, }, }, { Event: base.StringOrSlice{"cron"}, Cron: constraint.List{ Include: []string{"job1"}, }, }, }, }, Settings: map[string]any{ "foo": "bar", "baz": false, }, Ports: []string{"8080", "4443/tcp", "51820/udp"}, } got := Container{} err := yaml.Unmarshal(containerYaml, &got) assert.NoError(t, err) assert.EqualValues(t, want, got, "problem parsing container") } // TestUnmarshalContainers unmarshals a map of containers. The order is // retained and the container key may be used as the container name if a // name is not explicitly provided. func TestUnmarshalContainers(t *testing.T) { testdata := []struct { from string want []*Container }{ { from: "build: { image: golang }", want: []*Container{ { Name: "build", Image: "golang", }, }, }, { from: "test: { name: unit_test, image: node, settings: { normal_setting: true } }", want: []*Container{ { Name: "unit_test", Image: "node", Settings: map[string]any{ "normal_setting": true, }, }, }, }, { from: `publish-agent: image: print/env settings: repo: woodpeckerci/woodpecker-agent dry_run: true dockerfile: docker/Dockerfile.agent tag: [next, latest] when: branch: ${CI_REPO_DEFAULT_BRANCH} event: push`, want: []*Container{ { Name: "publish-agent", Image: "print/env", Settings: map[string]any{ "repo": "woodpeckerci/woodpecker-agent", "dockerfile": "docker/Dockerfile.agent", "tag": stringsToInterface("next", "latest"), "dry_run": true, }, When: constraint.When{ Constraints: []constraint.Constraint{ { Event: base.StringOrSlice{"push"}, Branch: constraint.List{Include: []string{"${CI_REPO_DEFAULT_BRANCH}"}}, }, }, }, }, }, }, { from: `publish-cli: image: print/env settings: repo: woodpeckerci/woodpecker-cli dockerfile: docker/Dockerfile.cli tag: [next] when: branch: ${CI_REPO_DEFAULT_BRANCH} event: push`, want: []*Container{ { Name: "publish-cli", Image: "print/env", Settings: map[string]any{ "repo": "woodpeckerci/woodpecker-cli", "dockerfile": "docker/Dockerfile.cli", "tag": stringsToInterface("next"), }, When: constraint.When{ Constraints: []constraint.Constraint{ { Event: base.StringOrSlice{"push"}, Branch: constraint.List{Include: []string{"${CI_REPO_DEFAULT_BRANCH}"}}, }, }, }, }, }, }, { from: `publish-cli: image: print/env when: - branch: ${CI_REPO_DEFAULT_BRANCH} event: push - event: pull_request`, want: []*Container{ { Name: "publish-cli", Image: "print/env", When: constraint.When{ Constraints: []constraint.Constraint{ { Event: base.StringOrSlice{"push"}, Branch: constraint.List{Include: []string{"${CI_REPO_DEFAULT_BRANCH}"}}, }, { Event: base.StringOrSlice{"pull_request"}, }, }, }, }, }, }, } for _, test := range testdata { in := []byte(test.from) got := ContainerList{} err := yaml.Unmarshal(in, &got) assert.NoError(t, err) assert.EqualValues(t, test.want, got.ContainerList, "problem parsing containers %q", test.from) } } // TestUnmarshalContainersErr unmarshals a container map where invalid inputs // are provided to verify error messages are returned. func TestUnmarshalContainersErr(t *testing.T) { testdata := []string{ "foo: { name: [ foo, bar] }", "- foo", } for _, test := range testdata { in := []byte(test) containers := new(ContainerList) err := yaml.Unmarshal(in, &containers) assert.Error(t, err, "wanted error for containers %q", test) } } func stringsToInterface(val ...string) []any { res := make([]any, len(val)) for i := range val { res[i] = val[i] } return res } func TestIsPlugin(t *testing.T) { assert.True(t, (&Container{}).IsPlugin()) assert.True(t, (&Container{ Commands: base.StringOrSlice([]string{}), }).IsPlugin()) assert.False(t, (&Container{ Commands: base.StringOrSlice([]string{"echo 'this is not a plugin'"}), }).IsPlugin()) assert.True(t, (&Container{ Entrypoint: base.StringOrSlice([]string{}), }).IsPlugin()) assert.False(t, (&Container{ Entrypoint: base.StringOrSlice([]string{"echo 'this is not a plugin'"}), }).IsPlugin()) } ================================================ FILE: pipeline/frontend/yaml/types/network.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "errors" "fmt" ) // Networks represents a list of service networks in compose file. // It has several representation, hence this specific struct. type Networks struct { Networks []*Network } // Network represents a service network in compose file. type Network struct { Name string `yaml:"-"` Aliases []string `yaml:"aliases,omitempty"` IPv4Address string `yaml:"ipv4_address,omitempty"` IPv6Address string `yaml:"ipv6_address,omitempty"` } // MarshalYAML implements the Marshaller interface. func (n Networks) MarshalYAML() (any, error) { m := map[string]*Network{} for _, network := range n.Networks { m[network.Name] = network } return m, nil } // UnmarshalYAML implements the Unmarshaler interface. func (n *Networks) UnmarshalYAML(unmarshal func(any) error) error { var sliceType []any if err := unmarshal(&sliceType); err == nil { n.Networks = []*Network{} for _, network := range sliceType { name, ok := network.(string) if !ok { return fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", name, name) } n.Networks = append(n.Networks, &Network{ Name: name, }) } return nil } var mapType map[any]any if err := unmarshal(&mapType); err == nil { n.Networks = []*Network{} for mapKey, mapValue := range mapType { name, ok := mapKey.(string) if !ok { return fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", name, name) } network, err := handleNetwork(name, mapValue) if err != nil { return err } n.Networks = append(n.Networks, network) } return nil } return errors.New("failed to unmarshal Networks") } func handleNetwork(name string, value any) (*Network, error) { if value == nil { return &Network{ Name: name, }, nil } switch v := value.(type) { case map[string]any: network := &Network{ Name: name, } var ok bool for mapKey, mapValue := range v { switch mapKey { case "aliases": aliases, ok := mapValue.([]any) if !ok { return &Network{}, fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", aliases, aliases) } network.Aliases = []string{} for _, alias := range aliases { a, ok := alias.(string) if !ok { return &Network{}, fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", aliases, aliases) } network.Aliases = append(network.Aliases, a) } case "ipv4_address": network.IPv4Address, ok = mapValue.(string) if !ok { return &Network{}, fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", network, network) } case "ipv6_address": network.IPv6Address, ok = mapValue.(string) if !ok { return &Network{}, fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", network, network) } default: // Ignorer unknown keys ? continue } } return network, nil default: return &Network{}, fmt.Errorf("failed to unmarshal Network: %#v", value) } } ================================================ FILE: pipeline/frontend/yaml/types/network_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestMarshalNetworks(t *testing.T) { networks := []struct { networks Networks expected string }{ { networks: Networks{}, expected: "{}\n", }, { networks: Networks{ Networks: []*Network{ { Name: "network1", }, { Name: "network2", }, }, }, expected: `network1: {} network2: {} `, }, { networks: Networks{ Networks: []*Network{ { Name: "network1", Aliases: []string{"alias1", "alias2"}, }, { Name: "network2", }, }, }, expected: `network1: aliases: - alias1 - alias2 network2: {} `, }, { networks: Networks{ Networks: []*Network{ { Name: "network1", Aliases: []string{"alias1", "alias2"}, }, { Name: "network2", IPv4Address: "172.16.238.10", IPv6Address: "2001:3984:3989::10", }, }, }, expected: `network1: aliases: - alias1 - alias2 network2: ipv4_address: 172.16.238.10 ipv6_address: 2001:3984:3989::10 `, }, } for _, network := range networks { bytes, err := yaml.Marshal(network.networks) assert.NoError(t, err) assert.Equal(t, network.expected, string(bytes), "should be equal") } } func TestUnmarshalNetworks(t *testing.T) { networks := []struct { yaml string expected *Networks }{ { yaml: `- network1 - network2`, expected: &Networks{ Networks: []*Network{ { Name: "network1", }, { Name: "network2", }, }, }, }, { yaml: `network1:`, expected: &Networks{ Networks: []*Network{ { Name: "network1", }, }, }, }, { yaml: `network1: {}`, expected: &Networks{ Networks: []*Network{ { Name: "network1", }, }, }, }, { yaml: `network1: aliases: - alias1 - alias2`, expected: &Networks{ Networks: []*Network{ { Name: "network1", Aliases: []string{"alias1", "alias2"}, }, }, }, }, { yaml: `network1: aliases: - alias1 - alias2 ipv4_address: 172.16.238.10 ipv6_address: 2001:3984:3989::10`, expected: &Networks{ Networks: []*Network{ { Name: "network1", Aliases: []string{"alias1", "alias2"}, IPv4Address: "172.16.238.10", IPv6Address: "2001:3984:3989::10", }, }, }, }, } for _, network := range networks { actual := &Networks{} err := yaml.Unmarshal([]byte(network.yaml), actual) assert.NoError(t, err) assert.EqualValues(t, network.expected, actual) } } ================================================ FILE: pipeline/frontend/yaml/types/volume.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "errors" "fmt" "strings" ) // Volumes represents a list of service volumes in compose file. // It has several representation, hence this specific struct. type Volumes struct { Volumes []*Volume } // Volume represent a service volume. type Volume struct { Source string `yaml:"-"` Destination string `yaml:"-"` AccessMode string `yaml:"-"` } // String implements the Stringer interface. func (v *Volume) String() string { var paths []string if v.Source != "" { paths = []string{v.Source, v.Destination} } else { paths = []string{v.Destination} } if v.AccessMode != "" { paths = append(paths, v.AccessMode) } return strings.Join(paths, ":") } // MarshalYAML implements the Marshaller interface. func (v Volumes) MarshalYAML() (any, error) { vs := []string{} for _, volume := range v.Volumes { vs = append(vs, volume.String()) } return vs, nil } // UnmarshalYAML implements the Unmarshaler interface. func (v *Volumes) UnmarshalYAML(unmarshal func(any) error) error { var sliceType []any if err := unmarshal(&sliceType); err == nil { v.Volumes = []*Volume{} for _, volume := range sliceType { name, ok := volume.(string) if !ok { return fmt.Errorf("cannot unmarshal '%v' to type %T into a string value", name, name) } elements := strings.SplitN(name, ":", 3) var vol *Volume //nolint:mnd switch { case len(elements) == 1: vol = &Volume{ Destination: elements[0], } case len(elements) == 2: vol = &Volume{ Source: elements[0], Destination: elements[1], } case len(elements) == 3: vol = &Volume{ Source: elements[0], Destination: elements[1], AccessMode: elements[2], } default: // FIXME return fmt.Errorf("") } v.Volumes = append(v.Volumes, vol) } return nil } return errors.New("failed to unmarshal Volumes") } ================================================ FILE: pipeline/frontend/yaml/types/volume_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) func TestMarshalVolumes(t *testing.T) { volumes := []struct { volumes Volumes expected string }{ { volumes: Volumes{}, expected: `[] `, }, { volumes: Volumes{ Volumes: []*Volume{ { Destination: "/in/the/container", }, }, }, expected: `- /in/the/container `, }, { volumes: Volumes{ Volumes: []*Volume{ { Source: "./a/path", Destination: "/in/the/container", AccessMode: "ro", }, }, }, expected: `- ./a/path:/in/the/container:ro `, }, { volumes: Volumes{ Volumes: []*Volume{ { Source: "./a/path", Destination: "/in/the/container", }, }, }, expected: `- ./a/path:/in/the/container `, }, { volumes: Volumes{ Volumes: []*Volume{ { Source: "./a/path", Destination: "/in/the/container", }, { Source: "named", Destination: "/in/the/container", }, }, }, expected: `- ./a/path:/in/the/container - named:/in/the/container `, }, } for _, volume := range volumes { bytes, err := yaml.Marshal(volume.volumes) assert.NoError(t, err) assert.Equal(t, volume.expected, string(bytes), "should be equal") } } func TestUnmarshalVolumes(t *testing.T) { volumes := []struct { yaml string expected *Volumes }{ { yaml: `- ./a/path:/in/the/container`, expected: &Volumes{ Volumes: []*Volume{ { Source: "./a/path", Destination: "/in/the/container", }, }, }, }, { yaml: `- /in/the/container`, expected: &Volumes{ Volumes: []*Volume{ { Destination: "/in/the/container", }, }, }, }, { yaml: `- /a/path:/in/the/container:ro`, expected: &Volumes{ Volumes: []*Volume{ { Source: "/a/path", Destination: "/in/the/container", AccessMode: "ro", }, }, }, }, { yaml: `- /a/path:/in/the/container - named:/somewhere/in/the/container`, expected: &Volumes{ Volumes: []*Volume{ { Source: "/a/path", Destination: "/in/the/container", }, { Source: "named", Destination: "/somewhere/in/the/container", }, }, }, }, } for _, volume := range volumes { actual := &Volumes{} err := yaml.Unmarshal([]byte(volume.yaml), actual) assert.NoError(t, err) assert.Equal(t, volume.expected, actual, "should be equal") } } ================================================ FILE: pipeline/frontend/yaml/types/workflow.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint" ) type ( // Workflow defines a workflow configuration. Workflow struct { When constraint.When `yaml:"when,omitempty"` Workspace Workspace `yaml:"workspace,omitempty"` Clone ContainerList `yaml:"clone,omitempty"` Steps ContainerList `yaml:"steps,omitempty"` Services ContainerList `yaml:"services,omitempty"` Labels map[string]string `yaml:"labels,omitempty"` DependsOn []string `yaml:"depends_on,omitempty"` SkipClone bool `yaml:"skip_clone,omitempty"` // Deprecated: use when.status. TODO remove in next major. RunsOn []string `yaml:"runs_on,omitempty"` } // Workspace defines a pipeline workspace. Workspace struct { Base string Path string } ) ================================================ FILE: pipeline/frontend/yaml/utils/image.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "strings" "github.com/distribution/reference" ) // trimImage returns the short image name without tag. func trimImage(name string) string { ref, err := reference.ParseAnyReference(name) if err != nil { return name } named, err := reference.ParseNamed(ref.String()) if err != nil { return name } named = reference.TrimNamed(named) return reference.FamiliarName(named) } // expandImage returns the fully qualified image name. func expandImage(name string) string { ref, err := reference.ParseAnyReference(name) if err != nil { return name } named, err := reference.ParseNamed(ref.String()) if err != nil { return name } named = reference.TagNameOnly(named) return named.String() } // MatchImage returns true if the image name matches // an image in the list. Note the image tag is not used // in the matching logic. func MatchImage(from string, to ...string) bool { from = trimImage(from) for _, match := range to { if from == trimImage(match) { return true } } return false } // MatchImageDynamic check if image is in list based on list. // If an list entry has a tag specified it only will match if both are the same, else the tag is ignored. func MatchImageDynamic(from string, to ...string) bool { fullFrom := expandImage(from) trimFrom := trimImage(from) for _, match := range to { if imageHasTag(match) { if fullFrom == expandImage(match) { return true } } else { if trimFrom == trimImage(match) { return true } } } return false } func imageHasTag(name string) bool { return strings.Contains(name, ":") } // ParseNamed parses an image as a reference to validate it then parses it as a named reference. func ParseNamed(image string) (reference.Named, error) { ref, err := reference.ParseAnyReference(image) if err != nil { return nil, err } return reference.ParseNamed(ref.String()) } // MatchHostname returns true if the image hostname // matches the specified hostname. func MatchHostname(image, hostname string) bool { named, err := ParseNamed(image) if err != nil { return false } if hostname == "index.docker.io" { hostname = "docker.io" } return reference.Domain(named) == hostname } ================================================ FILE: pipeline/frontend/yaml/utils/image_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "testing" "github.com/stretchr/testify/assert" ) func Test_trimImage(t *testing.T) { testdata := []struct { from string want string }{ { from: "golang", want: "golang", }, { from: "golang:latest", want: "golang", }, { from: "golang:1.0.0", want: "golang", }, { from: "library/golang", want: "golang", }, { from: "library/golang:latest", want: "golang", }, { from: "library/golang:1.0.0", want: "golang", }, { from: "index.docker.io/library/golang:1.0.0", want: "golang", }, { from: "docker.io/library/golang:1.0.0", want: "golang", }, { from: "gcr.io/library/golang:1.0.0", want: "gcr.io/library/golang", }, // error cases, return input unmodified { from: "foo/bar?baz:boo", want: "foo/bar?baz:boo", }, } for _, test := range testdata { assert.Equal(t, test.want, trimImage(test.from)) } } func Test_expandImage(t *testing.T) { testdata := []struct { from string want string }{ { from: "golang", want: "docker.io/library/golang:latest", }, { from: "golang:latest", want: "docker.io/library/golang:latest", }, { from: "golang:1.0.0", want: "docker.io/library/golang:1.0.0", }, { from: "library/golang", want: "docker.io/library/golang:latest", }, { from: "library/golang:latest", want: "docker.io/library/golang:latest", }, { from: "library/golang:1.0.0", want: "docker.io/library/golang:1.0.0", }, { from: "index.docker.io/library/golang:1.0.0", want: "docker.io/library/golang:1.0.0", }, { from: "gcr.io/golang", want: "gcr.io/golang:latest", }, { from: "gcr.io/golang:1.0.0", want: "gcr.io/golang:1.0.0", }, { from: "codeberg.org/6543/hello:latest@2c98dce11f78c2b4e40f513ca82f75035eb8cfa4957a6d8eb3f917ecaf77803", want: "codeberg.org/6543/hello:latest@2c98dce11f78c2b4e40f513ca82f75035eb8cfa4957a6d8eb3f917ecaf77803", }, // error cases, return input unmodified { from: "foo/bar?baz:boo", want: "foo/bar?baz:boo", }, } for _, test := range testdata { assert.Equal(t, test.want, expandImage(test.from)) } } func Test_imageHasTag(t *testing.T) { testdata := []struct { from string want bool }{ { from: "golang", want: false, }, { from: "golang:latest", want: true, }, { from: "golang:1.0.0", want: true, }, { from: "library/golang", want: false, }, { from: "library/golang:latest", want: true, }, { from: "library/golang:1.0.0", want: true, }, { from: "index.docker.io/library/golang:1.0.0", want: true, }, { from: "gcr.io/golang", want: false, }, { from: "gcr.io/golang:1.0.0", want: true, }, { from: "codeberg.org/6543/hello:latest@2c98dce11f78c2b4e40f513ca82f75035eb8cfa4957a6d8eb3f917ecaf77803", want: true, }, } for _, test := range testdata { assert.Equal(t, test.want, imageHasTag(test.from)) } } func Test_matchImage(t *testing.T) { testdata := []struct { from, to string want bool }{ { from: "golang", to: "golang", want: true, }, { from: "golang:latest", to: "golang", want: true, }, { from: "library/golang:latest", to: "golang", want: true, }, { from: "index.docker.io/library/golang:1.0.0", to: "golang", want: true, }, { from: "golang", to: "golang:latest", want: true, }, { from: "library/golang:latest", to: "library/golang", want: true, }, { from: "gcr.io/golang", to: "gcr.io/golang", want: true, }, { from: "gcr.io/golang:1.0.0", to: "gcr.io/golang", want: true, }, { from: "gcr.io/golang:latest", to: "gcr.io/golang", want: true, }, { from: "gcr.io/golang", to: "gcr.io/golang:latest", want: true, }, { from: "golang", to: "library/golang", want: true, }, { from: "golang", to: "gcr.io/project/golang", want: false, }, { from: "golang", to: "gcr.io/library/golang", want: false, }, { from: "golang", to: "gcr.io/golang", want: false, }, { from: "woodpeckerci/plugin-kaniko", to: "docker.io/woodpeckerci/plugin-kaniko", want: true, }, } for _, test := range testdata { assert.Equal(t, test.want, MatchImage(test.from, test.to)) } } func Test_matchImageDynamic(t *testing.T) { testdata := []struct { name, from string to []string want bool }{ { name: "simple compare", from: "golang", to: []string{"golang"}, want: true, }, { name: "compare non-taged image whit list who tag requirement", from: "golang", to: []string{"golang:v3.0"}, want: false, }, { name: "compare taged image whit list who tag no requirement", from: "golang:v3.0", to: []string{"golang"}, want: true, }, { name: "compare taged image whit list who has image with no tag requirement", from: "golang:1.0", to: []string{"golang", "golang:2.0"}, want: true, }, { name: "compare taged image whit list who only has images with tag requirement", from: "golang:1.0", to: []string{"golang:latest", "golang:2.0"}, want: false, }, { name: "compare taged image whit list who only has images with tag requirement", from: "golang:1.0", to: []string{"golang:latest", "golang:1.0"}, want: true, }, } for _, test := range testdata { if !assert.Equal(t, test.want, MatchImageDynamic(test.from, test.to...)) { t.Logf("test data: '%s' -> '%s'", test.from, test.to) } } } func Test_matchHostname(t *testing.T) { testdata := []struct { image, hostname string want bool }{ { image: "golang", hostname: "docker.io", want: true, }, { image: "golang:latest", hostname: "docker.io", want: true, }, { image: "golang:latest", hostname: "index.docker.io", want: true, }, { image: "library/golang:latest", hostname: "docker.io", want: true, }, { image: "docker.io/library/golang:1.0.0", hostname: "docker.io", want: true, }, { image: "gcr.io/golang", hostname: "docker.io", want: false, }, { image: "gcr.io/golang:1.0.0", hostname: "gcr.io", want: true, }, { image: "1.2.3.4:8000/golang:1.0.0", hostname: "1.2.3.4:8000", want: true, }, { image: "*&^%", hostname: "1.2.3.4:8000", want: false, }, } for _, test := range testdata { assert.Equal(t, test.want, MatchHostname(test.image, test.hostname)) } } ================================================ FILE: pipeline/logging/logger.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logging import ( "io" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // Logger handles the process logging. type Logger func(*backend_types.Step, io.ReadCloser) error ================================================ FILE: pipeline/runtime/helpers_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package runtime import ( "io" "testing" "time" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/logging" "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" tracer_mocks "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks" ) // newTestTracer creates a MockTracer that accepts any number of Trace calls. func newTestTracer(t *testing.T) *tracer_mocks.MockTracer { t.Helper() tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Return(nil).Maybe() return tracer } // newTestLogger creates a noop logger. func newTestLogger(t *testing.T) logging.Logger { return func(_ *types.Step, rc io.ReadCloser) error { _, _ = io.Copy(io.Discard, rc) return rc.Close() } } // getTracerStates extracts all state.State values passed to Trace() calls // on a mockery-generated MockTracer. Thread-safe because mock.Mock.Calls // is append-only and we only read after the workflow completes. func getTracerStates(tracer *tracer_mocks.MockTracer) []state.State { // for systems under load we wait for tracer to make it's calls time.Sleep(120 * time.Microsecond) var states []state.State for _, call := range tracer.Calls { if call.Method == "Trace" { s, _ := call.Arguments.Get(0).(*state.State) states = append(states, *s) } } return states } // indexOfTrace returns the first index where predicate matches, or -1. func indexOfTrace(traces []state.State, match func(s state.State) bool) int { for i := range traces { if match(traces[i]) { return i } } return -1 } ================================================ FILE: pipeline/runtime/option.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runtime import ( "context" "go.woodpecker-ci.org/woodpecker/v3/pipeline/logging" "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing" ) // Option configures a Runtime. type Option func(*Runtime) // WithLogger sets the function used to stream step logs. func WithLogger(logger logging.Logger) Option { return func(r *Runtime) { r.logger = logger } } // WithTracer sets the tracer used to report step state changes. func WithTracer(tracer tracing.Tracer) Option { return func(r *Runtime) { r.tracer = tracer } } // WithContext sets the workflow execution context. func WithContext(ctx context.Context) Option { return func(r *Runtime) { r.ctx = ctx } } // WithDescription sets the descriptive key-value pairs attached to every log line. func WithDescription(desc map[string]string) Option { return func(r *Runtime) { r.description = desc } } // WithTaskUUID sets a specific task UUID instead of the auto-generated one. func WithTaskUUID(uuid string) Option { return func(r *Runtime) { r.taskUUID = uuid } } ================================================ FILE: pipeline/runtime/runtime.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runtime import ( "context" "sync" "github.com/oklog/ulid/v2" "github.com/rs/zerolog" "github.com/rs/zerolog/log" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/logging" "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) // Runtime represents a workflow state executed by a specific backend. // Each workflow gets its own Runtime instance. type Runtime struct { // err holds the first error that occurred in the workflow. err utils.Protected[error] spec *backend_types.Config engine backend_types.Backend started int64 // ctx is the context for the current workflow execution. // All normal (non-cleanup) step operations must use this context. // Cleanup operations should use the runnerCtx passed to Run(). ctx context.Context tracer tracing.Tracer logger logging.Logger uploadWait sync.WaitGroup taskUUID string description map[string]string } // New returns a new Runtime for the given workflow spec and options. func New(spec *backend_types.Config, backend backend_types.Backend, opts ...Option) *Runtime { r := new(Runtime) r.err = utils.NewProtected[error](nil) r.description = map[string]string{} r.spec = spec r.engine = backend r.ctx = context.Background() r.taskUUID = ulid.Make().String() r.tracer = tracing.NoOpTracer for _, opt := range opts { opt(r) } return r } // makeLogger returns a logger enriched with all runtime description fields. func (r *Runtime) makeLogger() zerolog.Logger { logCtx := log.With() for key, val := range r.description { logCtx = logCtx.Str(key, val) } return logCtx.Logger() } ================================================ FILE: pipeline/runtime/runtime_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package runtime import ( "context" "errors" "fmt" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" tracer_mocks "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks" ) // // Step builder helpers. // func cmdStep(name string, opts ...func(*backend_types.Step)) *backend_types.Step { s := &backend_types.Step{ Name: name, UUID: name + "-uuid", Type: backend_types.StepTypeCommands, OnSuccess: true, OnFailure: false, Environment: map[string]string{}, Commands: []string{"echo " + name}, } for _, o := range opts { o(s) } return s } func withExitCode(code int) func(*backend_types.Step) { return func(s *backend_types.Step) { s.Environment[dummy.EnvKeyStepExitCode] = fmt.Sprintf("%d", code) } } func withIgnoreFailure() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Failure = string(metadata.FailureIgnore) } } func withOnFailure() func(*backend_types.Step) { return func(s *backend_types.Step) { s.OnSuccess = false; s.OnFailure = true } } func withDetached() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Detached = true s.Environment[dummy.EnvKeyStepSleep] = "100ms" } } // withUnboundedDetached models a detached step that runs until the workflow tears it down. func withUnboundedDetached() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Type = backend_types.StepTypeService s.Detached = true s.Environment[dummy.EnvKeyStepSleep] = "3m" } } func withService() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Type = backend_types.StepTypeService s.Detached = true s.Environment[dummy.EnvKeyStepSleep] = "100ms" } } // withUnboundedService models a real-world service that runs until the workflow tears it down. func withUnboundedService() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Type = backend_types.StepTypeService s.Detached = true s.Environment[dummy.EnvKeyStepSleep] = "3m" } } func withPlugin() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Type = backend_types.StepTypePlugin s.Environment[dummy.EnvKeyStepType] = "plugin" } } func withOOM() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Environment[dummy.EnvKeyStepOOMKilled] = "true" s.Environment[dummy.EnvKeyStepExitCode] = "137" } } func withStartFail() func(*backend_types.Step) { return func(s *backend_types.Step) { s.Environment[dummy.EnvKeyStepStartFail] = "true" } } func withSleep(d string) func(*backend_types.Step) { return func(s *backend_types.Step) { s.Environment[dummy.EnvKeyStepSleep] = d } } // // Trace assertion helpers. // func findFirstTraceByName(traces []state.State, name string) *state.State { for i := range traces { if traces[i].CurrStep != nil && traces[i].CurrStep.Name == name { return &traces[i] } } return nil } func findLastTraceByName(traces []state.State, name string) *state.State { for i := len(traces) - 1; i >= 0; i-- { if traces[i].CurrStep != nil && traces[i].CurrStep.Name == name { return &traces[i] } } return nil } func findStartedTrace(traces []state.State, name string) *state.State { for i := range traces { if traces[i].CurrStep != nil && traces[i].CurrStep.Name == name && !traces[i].CurrStepState.Exited { return &traces[i] } } return nil } // // Realistic workflow simulations. // func TestWorkflowCloneBuildDeploy(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("clone")}}, {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context())) traces := getTracerStates(tracer) assert.Len(t, traces, 6) for i := 0; i < 6; i += 2 { assert.False(t, traces[i].CurrStepState.Exited, "trace %d should be step-started", i) assert.True(t, traces[i+1].CurrStepState.Exited, "trace %d should be step-completed", i+1) assert.Equal(t, 0, traces[i+1].CurrStepState.ExitCode) } for _, name := range []string{"clone", "build", "deploy"} { last := findLastTraceByName(traces, name) require.NotNil(t, last, "%s should have a final trace", name) assert.True(t, last.CurrStepState.Exited, "%s last trace should be exited", name) assert.Equal(t, 0, last.CurrStepState.ExitCode, "%s should exit with code 0", name) assert.False(t, last.CurrStepState.OOMKilled, "%s should not be OOM killed", name) } } func TestWorkflowWithServiceStep(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("db", withService()), cmdStep("build"), }}, {Steps: []*backend_types.Step{cmdStep("test", withSleep("250ms"))}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) require.NoError(t, r.Run(t.Context())) traces := getTracerStates(tracer) // Each step should emit exactly one "started" and one "exited" trace: // db (service/detached), build, test — 3 * 2 = 6 traces total. require.Len(t, traces, 6) // Per-step invariants: started trace is the zero state, exited trace is // Exited=true with a monotonic Started timestamp. for _, name := range []string{"db", "build", "test"} { started := findFirstTraceByName(traces, name) require.NotNil(t, started, "%s should have a started trace", name) assert.EqualValues(t, backend_types.State{}, started.CurrStepState, "%s started trace should be zero-valued", name) last := findLastTraceByName(traces, name) require.NotNil(t, last, "%s should have an exited trace", name) assert.True(t, last.CurrStepState.Exited, "%s should be exited", name) assert.Equal(t, 0, last.CurrStepState.ExitCode, "%s should exit 0", name) assert.Greater(t, last.CurrStepState.Started, int64(0), "%s should have a non-zero Started timestamp", name) } // Per-step ordering: started trace precedes exited trace for the same step. for _, name := range []string{"db", "build", "test"} { startedIdx := indexOfTrace(traces, func(s state.State) bool { return s.CurrStep != nil && s.CurrStep.Name == name && !s.CurrStepState.Exited }) exitedIdx := indexOfTrace(traces, func(s state.State) bool { return s.CurrStep != nil && s.CurrStep.Name == name && s.CurrStepState.Exited }) assert.Less(t, startedIdx, exitedIdx, "%s started must precede %s exited", name, name) } // The contract of a service/detached step: it does not block the next // stage. Verify that stage 2's `test` step started before db (in stage 1) // reported its exit — i.e. test was running in parallel with db, not // queued behind it. dbExitIdx := indexOfTrace(traces, func(s state.State) bool { return s == *findLastTraceByName(traces, "db") }) testStartedIdx := indexOfTrace(traces, func(s state.State) bool { return s == *findFirstTraceByName(traces, "test") }) assert.Less(t, testStartedIdx, dbExitIdx, "test (next stage) must start before db (service) exits — otherwise db blocked stage 2") // Runtime-injected env vars should be present on the test step's exit trace. testExit := findLastTraceByName(traces, "test") require.NotNil(t, testExit) assert.NotEmpty(t, testExit.CurrStep.Environment["CI_PIPELINE_STARTED"]) assert.NotEmpty(t, testExit.CurrStep.Environment["CI_STEP_STARTED"]) assert.Greater(t, testExit.Workflow.Started, int64(0)) // Strip runtime-injected env for a structural comparison of the step itself. delete(testExit.CurrStep.Environment, "CI_STEP_STARTED") delete(testExit.CurrStep.Environment, dummy.EnvKeyStepSleep) assert.EqualValues(t, state.State{ Workflow: state.Workflow{Started: testExit.Workflow.Started}, CurrStep: &backend_types.Step{ Name: "test", UUID: "test-uuid", Type: "commands", OnSuccess: true, Environment: map[string]string{ "CI_PIPELINE_STARTED": fmt.Sprintf("%d", r.started), "CI_PIPELINE_STATUS": "success", "CI_STEP_NAME": "test", "CI_STEP_TYPE": "commands", }, Commands: []string{"echo test"}, }, CurrStepState: backend_types.State{ Started: testExit.CurrStepState.Started, Exited: true, }, }, *testExit) } func TestWorkflowDetachedStepDoesNotBlockWorkflow(t *testing.T) { t.Parallel() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("background-worker", withDetached()), cmdStep("main-build"), }}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context())) } func TestWorkflowBuildFailSkipsSubsequentStages(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("clone")}}, {Steps: []*backend_types.Step{cmdStep("build", withExitCode(1))}}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) var exitErr *pipeline_errors.ExitError require.True(t, errors.As(err, &exitErr)) assert.Equal(t, 1, exitErr.Code) traces := getTracerStates(tracer) buildTrace := findLastTraceByName(traces, "build") require.NotNil(t, buildTrace, "build step should fail") assert.EqualValues(t, 1, buildTrace.CurrStepState.ExitCode) assert.True(t, buildTrace.CurrStepState.Exited, "build should have started") buildTrace = findLastTraceByName(traces, "build") require.NotNil(t, buildTrace, "build step should fail") assert.EqualValues(t, 1, buildTrace.CurrStepState.ExitCode) deployTrace := findLastTraceByName(traces, "deploy") require.NotNil(t, deployTrace, "deploy step should still be traced") assert.True(t, deployTrace.CurrStepState.Skipped) } func TestWorkflowOnFailureStepRuns(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build", withExitCode(2))}}, {Steps: []*backend_types.Step{cmdStep("notify-failure", withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) traces := getTracerStates(tracer) assert.Error(t, err) assert.NotNil(t, findStartedTrace(traces, "notify-failure"), "OnFailure step should have started") last := findLastTraceByName(traces, "notify-failure") require.NotNil(t, last) assert.Greater(t, last.CurrStepState.Started, int64(0), "step should have started") assert.EqualValues(t, backend_types.State{Started: last.CurrStepState.Started, Exited: true}, last.CurrStepState) } func TestWorkflowOnFailureStepSkippedOnSuccess(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("cleanup-on-fail", withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) require.NoError(t, r.Run(t.Context())) traces := getTracerStates(tracer) firstCleanupTrace := findFirstTraceByName(traces, "cleanup-on-fail") lastCleanupTrace := findLastTraceByName(traces, "cleanup-on-fail") assert.Equal(t, firstCleanupTrace, lastCleanupTrace, "we expect on skipped steps to only have one trace") assert.True(t, lastCleanupTrace.CurrStepState.Skipped, "cleanup-on-fail should be skipped after no failure happened") } func TestWorkflowFailureIgnore(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("lint", withExitCode(1), withIgnoreFailure()), }}, {Steps: []*backend_types.Step{cmdStep("build")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context()), "pipeline should succeed when failing step has failure=ignore") assert.NotNil(t, findStartedTrace(getTracerStates(tracer), "build"), "build step should run after ignored failure") last := findLastTraceByName(getTracerStates(tracer), "build") require.NotNil(t, last) assert.True(t, last.CurrStepState.Exited) assert.Equal(t, 0, last.CurrStepState.ExitCode) } func TestWorkflowFailureIgnoreDoesNotSetWorkflowError(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("flaky-test", withExitCode(1), withIgnoreFailure()), }}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context())) traces := getTracerStates(tracer) firstDeployTrace := findFirstTraceByName(traces, "deploy") lastDeployTrace := findLastTraceByName(traces, "deploy") assert.NotEqualValues(t, firstDeployTrace, lastDeployTrace, "we expect two traces") assert.False(t, lastDeployTrace.CurrStepState.Skipped, "deploy should not be skipped after failure=ignore step") } func TestWorkflowPluginStep(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("clone")}}, {Steps: []*backend_types.Step{cmdStep("publish", withPlugin())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context())) lastPluginTrace := findLastTraceByName(getTracerStates(tracer), "publish") if assert.NotNil(t, lastPluginTrace) { delete(lastPluginTrace.CurrStep.Environment, "CI_PIPELINE_STARTED") delete(lastPluginTrace.CurrStep.Environment, "CI_STEP_STARTED") assert.EqualValues(t, map[string]string{ "CI_PIPELINE_STATUS": "success", "CI_STEP_NAME": "publish", "CI_STEP_TYPE": "plugin", "DRONE_BUILD_STATUS": "success", "DRONE_REPO_SCM": "git", "EXPECT_TYPE": "plugin", "PULLREQUEST_DRONE_PULL_REQUEST": "0", }, lastPluginTrace.CurrStep.Environment) } } func TestWorkflowOOMKilledStep(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build", withOOM())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) var oomErr *pipeline_errors.OomError assert.True(t, errors.As(err, &oomErr)) last := findLastTraceByName(getTracerStates(tracer), "build") require.NotNil(t, last) assert.True(t, last.CurrStepState.Exited) assert.True(t, last.CurrStepState.OOMKilled) assert.Equal(t, 137, last.CurrStepState.ExitCode) } func TestWorkflowParallelStepsInStage(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("clone")}}, {Steps: []*backend_types.Step{ cmdStep("test-unit"), cmdStep("test-integration"), cmdStep("test-e2e"), }}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context())) assert.Len(t, getTracerStates(tracer), 10) } func TestWorkflowParallelStepOneFailsOthersComplete(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("test-fast"), cmdStep("test-slow", withExitCode(1)), }}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.Error(t, r.Run(t.Context())) assert.Len(t, getTracerStates(tracer), 4, "both parallel steps should complete and be traced") lastFast := findLastTraceByName(getTracerStates(tracer), "test-fast") require.NotNil(t, lastFast) assert.True(t, lastFast.CurrStepState.Exited) assert.Equal(t, 0, lastFast.CurrStepState.ExitCode, "test-fast should succeed") lastSlow := findLastTraceByName(getTracerStates(tracer), "test-slow") require.NotNil(t, lastSlow) assert.True(t, lastSlow.CurrStepState.Exited) assert.Equal(t, 1, lastSlow.CurrStepState.ExitCode, "test-slow should fail with code 1") } func TestWorkflowStepStartFailure(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("deploy", withStartFail())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.Error(t, r.Run(t.Context())) deployTrace := findFirstTraceByName(getTracerStates(tracer), "build") require.NotNil(t, deployTrace) assert.EqualValues(t, backend_types.State{}, deployTrace.CurrStepState) } func TestWorkflowContextCancelDuringExecution(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancelCause(t.Context()) var stageCount int tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Run(func(args mock.Arguments) { s, _ := args.Get(0).(*state.State) if s.CurrStepState.Exited && !s.CurrStepState.Skipped { stageCount++ if stageCount >= 1 { cancel(nil) } } }).Return(nil).Maybe() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithContext(ctx), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.ErrorIs(t, err, pipeline_errors.ErrCancel) } func TestWorkflowSetupFailure(t *testing.T) { t.Parallel() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithTaskUUID(dummy.WorkflowSetupFailUUID), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) assert.Contains(t, err.Error(), "expected fail to setup workflow") } func TestWorkflowServiceWithParallelBuildAndOnFailure(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("redis", withService()), cmdStep("clone"), }}, {Steps: []*backend_types.Step{ cmdStep("build"), cmdStep("lint", withExitCode(1)), }}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, {Steps: []*backend_types.Step{cmdStep("notify", withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.Error(t, r.Run(t.Context())) traces := getTracerStates(tracer) assert.NotNil(t, findStartedTrace(traces, "notify"), "notify (OnFailure) should have started") notifyTrace := findLastTraceByName(traces, "notify") require.NotNil(t, notifyTrace) assert.True(t, notifyTrace.CurrStepState.Exited, "notify should exited") assert.EqualValues(t, 0, notifyTrace.CurrStepState.ExitCode, "notify should be successful") lastBuild := findLastTraceByName(traces, "lint") require.NotNil(t, lastBuild) assert.True(t, lastBuild.CurrStepState.Exited) assert.Equal(t, 1, lastBuild.CurrStepState.ExitCode, "lint should have failed") deployTrace := findFirstTraceByName(traces, "deploy") require.NotNil(t, deployTrace) assert.True(t, deployTrace.CurrStepState.Skipped, "deploy should be skipped after lint failure") } func TestWorkflowIgnoredFailureFollowedByOnFailureStep(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("lint", withExitCode(1), withIgnoreFailure()), }}, {Steps: []*backend_types.Step{cmdStep("error-notify", withOnFailure())}}, {Steps: []*backend_types.Step{cmdStep("build")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) traces := getTracerStates(tracer) notifyTrace := findFirstTraceByName(traces, "error-notify") require.NotNil(t, notifyTrace) assert.True(t, notifyTrace.CurrStepState.Skipped, "OnFailure step should be skipped when prior failure was ignored") assert.NotNil(t, findStartedTrace(traces, "build"), "build should run after ignored failure") } func TestWorkflowEmptyStages(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{Stages: []*backend_types.Stage{}}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) assert.Empty(t, getTracerStates(tracer)) } // // outcome: failure // func TestPluginStepFailure(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("publish", withPlugin(), withExitCode(1))}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) var exitErr *pipeline_errors.ExitError require.True(t, errors.As(err, &exitErr)) assert.Equal(t, 1, exitErr.Code) last := findLastTraceByName(getTracerStates(tracer), "publish") require.NotNil(t, last) assert.True(t, last.CurrStepState.Exited) assert.Equal(t, 1, last.CurrStepState.ExitCode) } func TestDetachedStepFailure(t *testing.T) { t.Parallel() // A detached step that exits non-zero; since it is detached the runtime // only waits for setup, so the pipeline itself should still succeed. r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("background", withDetached(), withExitCode(1)), cmdStep("build"), }}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) // Detached step errors are not propagated to the pipeline result. assert.NoError(t, r.Run(t.Context())) } func TestServiceStepFailure(t *testing.T) { t.Parallel() // A service that exits non-zero; same semantics as detached — the pipeline // should still complete because services are fire-and-forget from the // runtime's perspective. r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("db", withService(), withExitCode(1)), cmdStep("test"), }}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context())) } // // outcome: start failure // func TestPluginStepStartFailure(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("publish", withPlugin(), withStartFail())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) } func TestDetachedStepStartFailure(t *testing.T) { t.Parallel() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("background", withDetached(), withStartFail()), cmdStep("build"), }}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) // A detached step that fails to start should surface the error, since the // runtime waits for setup to complete before continuing. err := r.Run(t.Context()) assert.Error(t, err) } func TestServiceStepStartFailure(t *testing.T) { t.Parallel() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("db", withService(), withStartFail()), cmdStep("test"), }}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) } // // Run condition: OnFailure for plugin / detached / service. // func TestPluginOnFailureStepRuns(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build", withExitCode(1))}}, {Steps: []*backend_types.Step{cmdStep("notify", withPlugin(), withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) assert.NotNil(t, findStartedTrace(getTracerStates(tracer), "notify"), "plugin OnFailure step should have started") last := findLastTraceByName(getTracerStates(tracer), "notify") require.NotNil(t, last) assert.True(t, last.CurrStepState.Exited) assert.Equal(t, 0, last.CurrStepState.ExitCode) } func TestPluginOnFailureStepSkippedOnSuccess(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("notify", withPlugin(), withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) trace := findLastTraceByName(getTracerStates(tracer), "notify") trace.CurrStepState.Started = 0 assert.EqualValues(t, backend_types.State{Skipped: true}, trace.CurrStepState, "plugin OnFailure step should not run when pipeline succeeds") } func TestDetachedOnFailureStepRuns(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build", withExitCode(1))}}, {Steps: []*backend_types.Step{cmdStep("cleanup", withDetached(), withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) assert.NotNil(t, findStartedTrace(getTracerStates(tracer), "cleanup"), "detached OnFailure step should have started") } func TestDetachedOnFailureStepSkippedOnSuccess(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("cleanup", withDetached(), withOnFailure())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) trace := findLastTraceByName(getTracerStates(tracer), "cleanup") trace.CurrStepState.Started = 0 assert.EqualValues(t, backend_types.State{Skipped: true}, trace.CurrStepState, "detached OnFailure step should not run when pipeline succeeds") } // // Run condition: OnSuccess=true + OnFailure=true (always-run). // func withAlwaysRun() func(*backend_types.Step) { return func(s *backend_types.Step) { s.OnSuccess = true; s.OnFailure = true } } func TestAlwaysRunStepRunsOnSuccess(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("report", withAlwaysRun())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) last := findLastTraceByName(getTracerStates(tracer), "report") require.NotNil(t, last, "always-run step should be traced") assert.True(t, last.CurrStepState.Exited) assert.Equal(t, 0, last.CurrStepState.ExitCode) } func TestAlwaysRunStepRunsOnFailure(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build", withExitCode(1))}}, {Steps: []*backend_types.Step{cmdStep("report", withAlwaysRun())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) assert.NotNil(t, findStartedTrace(getTracerStates(tracer), "report"), "always-run step should start even when pipeline is failing") last := findLastTraceByName(getTracerStates(tracer), "report") require.NotNil(t, last) assert.True(t, last.CurrStepState.Exited) assert.Equal(t, 0, last.CurrStepState.ExitCode) } func TestAlwaysRunPluginRunsOnFailure(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build", withExitCode(1))}}, {Steps: []*backend_types.Step{cmdStep("report", withPlugin(), withAlwaysRun())}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) assert.NotNil(t, findStartedTrace(getTracerStates(tracer), "report"), "always-run plugin step should start even when pipeline is failing") } // // Failure handling: failure=ignore for plugin. // func TestPluginFailureIgnore(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("lint", withPlugin(), withExitCode(1), withIgnoreFailure()), }}, {Steps: []*backend_types.Step{cmdStep("build")}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err, "pipeline should succeed when failing plugin has failure=ignore") assert.NotNil(t, findStartedTrace(getTracerStates(tracer), "build"), "build step should run after ignored plugin failure") } func TestDetachedFailureIgnore(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("watcher", withDetached(), withExitCode(1), withIgnoreFailure()), cmdStep("build"), }}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) } // // Cancellation. // func TestWorkflowContextCancelWithPluginStep(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancelCause(t.Context()) var stageCount int tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Run(func(args mock.Arguments) { s, _ := args.Get(0).(*state.State) if s.CurrStepState.Exited { stageCount++ if stageCount >= 1 { cancel(nil) } } }).Return(nil).Maybe() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{cmdStep("build")}}, {Steps: []*backend_types.Step{cmdStep("publish", withPlugin())}}, }, }, dummy.New(), WithTracer(tracer), WithContext(ctx), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.ErrorIs(t, err, pipeline_errors.ErrCancel) } func TestWorkflowContextCancelWithDetachedStep(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancelCause(t.Context()) var stageCount int tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Run(func(args mock.Arguments) { s, _ := args.Get(0).(*state.State) if s.CurrStepState.Exited { stageCount++ if stageCount >= 1 { cancel(nil) } } }).Return(nil).Maybe() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("background", withDetached()), cmdStep("build"), }}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithContext(ctx), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.ErrorIs(t, err, pipeline_errors.ErrCancel) } func TestWorkflowContextCancelWithServiceStep(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancelCause(t.Context()) var stageCount int tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Run(func(args mock.Arguments) { s, _ := args.Get(0).(*state.State) if s.CurrStepState.Exited { stageCount++ if stageCount >= 1 { cancel(nil) } } }).Return(nil).Maybe() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("db", withService()), cmdStep("build"), }}, {Steps: []*backend_types.Step{cmdStep("deploy")}}, }, }, dummy.New(), WithTracer(tracer), WithContext(ctx), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.ErrorIs(t, err, pipeline_errors.ErrCancel) } // TestWorkflowCancelDuringStepSleep verifies that canceling the workflow context // while a step is sleeping (via SLEEP env) causes the runtime to return ErrCancel // promptly — without waiting the full sleep duration — and that subsequent stages // are never executed. // // The tracer callback cancels the context the moment the first stage ("prepare") // completes. The "slow" step uses a short sleep so that even if WaitStep enters // the sleep select, the context cancellation unblocks it quickly. // // Note: we do not assert on the slow step's exit code here because Run() may // return (via ctx.Done()) before the stage goroutine's WaitStep completes, // causing DestroyWorkflow to clean up state that WaitStep still needs. The // exit-code-130 behavior of a canceled sleep is verified at the backend unit // level in TestWaitStepCanceledBySleep. func TestWorkflowCancelDuringStepSleep(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancelCause(t.Context()) var prepareExited int tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Run(func(args mock.Arguments) { s, _ := args.Get(0).(*state.State) if s == nil || s.CurrStep == nil { return } // Cancel as soon as the first stage ("prepare") finishes. if s.CurrStep.Name == "prepare" && s.CurrStepState.Exited { prepareExited++ if prepareExited >= 1 { cancel(nil) } } }).Return(nil).Maybe() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("prepare"), }}, {Steps: []*backend_types.Step{ // Short sleep so the test doesn't hang if WaitStep enters the timer. cmdStep("slow", func(s *backend_types.Step) { s.Environment[dummy.EnvKeyStepSleep] = "100ms" }), }}, {Steps: []*backend_types.Step{ cmdStep("never-reached"), }}, }, }, dummy.New(), WithTracer(tracer), WithContext(ctx), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.ErrorIs(t, err, pipeline_errors.ErrCancel, "canceled workflow must return ErrCancel") // Give the orphaned stage goroutine a moment to finish tracing (best effort). time.Sleep(200 * time.Millisecond) assert.Nil(t, findFirstTraceByName(getTracerStates(tracer), "never-reached"), "never-reached must not have been traced") } // TestWorkflowFailingServiceDoesNotFailWorkflow pins down the intentional design: // a service/detached step that fails in the background has its failure logged // and traced, but it must NOT propagate to the workflow error. Subsequent // stages must still run, and Run() must return nil. // // This is the explicit contract in runDetachedStep: // "Any error that occurs after setup is logged but not propagated — it cannot // // influence the pipeline outcome at that point." func TestWorkflowFailingServiceDoesNotFailWorkflow(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ // Service runs ~100ms (from withService), then exits non-zero. cmdStep("db", withService(), withExitCode(1)), cmdStep("build"), }}, {Steps: []*backend_types.Step{cmdStep("deploy", withSleep("120ms"))}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) // Contract 1: workflow succeeds even though the service failed. assert.NoError(t, r.Run(t.Context()), "service failure must not fail the workflow (detached errors are not propagated)") traces := getTracerStates(tracer) // Contract 2: the service's failure IS visible in traces. This is the // observability guarantee — the failure is logged and recorded even though // it doesn't kill the workflow. dbExit := findLastTraceByName(traces, "db") require.NotNil(t, dbExit, "db must have an exit trace") assert.True(t, dbExit.CurrStepState.Exited, "db should be marked exited") assert.Equal(t, 1, dbExit.CurrStepState.ExitCode, "db exit code must be preserved in trace") // Contract 3: deploy must run normally — NOT skipped — because the service // failure didn't set r.err. deployExit := findLastTraceByName(traces, "deploy") require.NotNil(t, deployExit, "deploy must be traced") assert.False(t, deployExit.CurrStepState.Skipped, "deploy must run when only a service failed") assert.True(t, deployExit.CurrStepState.Exited, "deploy should complete normally") assert.Equal(t, 0, deployExit.CurrStepState.ExitCode) // Contract 4: uploadWait at the end of Run() guarantees the detached trace // has been emitted BEFORE Run() returns. This is non-timing-dependent: // if Run() returned, the exit trace for every detached step must exist. // This is what the uploadWait plumbing in this PR is actually for. assert.NotNil(t, findLastTraceByName(traces, "db"), "detached step exit trace must be emitted before Run() returns (uploadWait contract)") } // TestWorkflowFailingDetachedStepDoesNotFailWorkflow is the non-service // counterpart: Detached=true, Type=commands (a background worker). Same // contract — failures don't propagate. func TestWorkflowFailingDetachedStepDoesNotFailWorkflow(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ // Detached (non-service) worker, ~100ms (from withDetached), exits code 2. cmdStep("background-worker", withDetached(), withExitCode(2)), cmdStep("main-build"), }}, {Steps: []*backend_types.Step{cmdStep("deploy", withSleep("120ms"))}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) assert.NoError(t, r.Run(t.Context()), "detached worker failure must not fail the workflow") traces := getTracerStates(tracer) workerExit := findLastTraceByName(traces, "background-worker") require.NotNil(t, workerExit, "background-worker must have an exit trace") assert.True(t, workerExit.CurrStepState.Exited) assert.Equal(t, 2, workerExit.CurrStepState.ExitCode, "exit code from detached step must be preserved in trace") deployExit := findLastTraceByName(traces, "deploy") require.NotNil(t, deployExit, "deploy must be traced") assert.False(t, deployExit.CurrStepState.Skipped, "deploy must run when only a detached worker failed") assert.True(t, deployExit.CurrStepState.Exited) assert.Equal(t, 0, deployExit.CurrStepState.ExitCode) } // TestWorkflowUnboundedServiceDoesNotHang asserts that when all normal steps // have finished, a long-running service does NOT keep the workflow blocked // forever. The runtime must tear the service down on its own (the whole point // of declaring a step as a service is that it runs alongside the build, not // that the build waits for it). // // Regression for https://github.com/woodpecker-ci/woodpecker/commit/4dd3be7f96 // which moved the upload waitgroup from per-upload (logger/tracer) to // per-detached-goroutine. The detached goroutine wraps WaitStep, which on // services blocks until the workflow context is canceled — so the workflow // hangs waiting for its own service to exit. func TestWorkflowUnboundedServiceDoesNotHang(t *testing.T) { t.Parallel() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("db", withUnboundedService()), cmdStep("build"), }}, {Steps: []*backend_types.Step{cmdStep("test")}}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) // Use a deadline well below the dummy backend's testServiceTimeout (1s) so // that if this test "passes" it's because the runtime tore the service down, // not because dummy's safety timeout fired. done := make(chan error, 1) go func() { done <- r.Run(t.Context()) }() select { case err := <-done: assert.NoError(t, err) case <-time.After(500 * time.Millisecond): t.Fatal("workflow hung: runtime did not tear down the unbounded service after normal steps finished") } } // TestWorkflowUnboundedDetachedDoesNotHang is the same as the service test but // for plain detached steps (Detached=true, Type=commands). The bug is the same // — a long-running detached step also pins the upload waitgroup. func TestWorkflowUnboundedDetachedDoesNotHang(t *testing.T) { t.Parallel() r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{ cmdStep("background-worker", withUnboundedDetached()), cmdStep("build"), }}, {Steps: []*backend_types.Step{cmdStep("test")}}, }, }, dummy.New(), WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) done := make(chan error, 1) go func() { done <- r.Run(t.Context()) }() select { case err := <-done: assert.NoError(t, err) case <-time.After(500 * time.Millisecond): t.Fatal("workflow hung: runtime did not tear down the unbounded detached step after normal steps finished") } } ================================================ FILE: pipeline/runtime/shutdown.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runtime import ( "context" "sync" "time" ) const shutdownTimeout = time.Second * 5 var ( shutdownCtx context.Context shutdownCtxLock sync.Mutex ) // GetShutdownCtx returns a context that is valid for shutdownTimeout after the // first call. It is used as a fallback cleanup context when the runner context // is already canceled. func GetShutdownCtx() context.Context { shutdownCtxLock.Lock() defer shutdownCtxLock.Unlock() if shutdownCtx == nil { shutdownCtx, _ = context.WithTimeout(context.Background(), shutdownTimeout) //nolint:govet } return shutdownCtx } ================================================ FILE: pipeline/runtime/step.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runtime import ( "context" "errors" "fmt" "strconv" "sync" "time" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" ) // executeStep is the single entry point called per step from runStage. // It checks whether the step should be skipped, emits a "started" trace, // sets up drone-compat env vars, then hands off to blocking or detached execution. func (r *Runtime) executeStep(runnerCtx context.Context, step *backend_types.Step) error { logger := r.makeLogger() logger.Debug().Str("step", step.Name).Msg("prepare") if r.shouldSkipStep(step) { // Trace the skip so the server marks the step as skipped immediately, // rather than leaving it in "pending" until workflow Done. return r.traceStep(&backend_types.State{Skipped: true}, nil, step) } // Emit a "step started" trace before doing any real work. if err := r.traceStep(nil, nil, step); err != nil { return err } // Set runtime specific step environment if err := r.setStepEnv(step); err != nil { return err } logger.Debug().Str("step", step.Name).Msg("executing") if step.Detached { return r.runDetachedStep(runnerCtx, step) } return r.runBlockingStep(runnerCtx, step) } // shouldSkipStep returns true when the step should not run based on the current // pipeline error state and the step's OnSuccess / OnFailure flags. // It logs the reason for skipping before returning. func (r *Runtime) shouldSkipStep(step *backend_types.Step) bool { logger := r.makeLogger() currentErr := r.err.Get() if currentErr != nil && !step.OnFailure { logger.Debug(). Str("step", step.Name). Err(currentErr). Msgf("skipped due to OnFailure=%t", step.OnFailure) return true } if currentErr == nil && !step.OnSuccess { logger.Debug(). Str("step", step.Name). Msgf("skipped due to OnSuccess=%t", step.OnSuccess) return true } return false } // setStepEnv sets runtime specific step environment variables. // It also adds the drone plugin compatibility layer. func (r *Runtime) setStepEnv(step *backend_types.Step) error { if step.Environment == nil { return fmt.Errorf("step %q (%q) has no environment variables initialized", step.Name, step.UUID) } // Add compatibility environment variables for drone-ci plugins. if step.Type == backend_types.StepTypePlugin { metadata.SetDroneEnviron(step.Environment) } if r.err.Get() != nil { step.Environment["CI_PIPELINE_STATUS"] = "failure" } else { step.Environment["CI_PIPELINE_STATUS"] = "success" } step.Environment["CI_PIPELINE_STARTED"] = strconv.FormatInt(r.started, 10) step.Environment["CI_STEP_STARTED"] = strconv.FormatInt(time.Now().Unix(), 10) step.Environment["CI_STEP_TYPE"] = string(step.Type) step.Environment["CI_STEP_NAME"] = step.Name return nil } // startStep starts the step container and spawns a goroutine to stream its logs. // It returns: // - waitForLogs: must be called before WaitStep — it blocks until the log stream // is fully drained. Some backends (e.g. local) close the log stream when // WaitStep is called, so draining first is required. // - startTime: unix timestamp recorded right after the container started, used // later to fill waitState.Started. // // If StartStep or TailStep fail, startStep returns a non-nil error and the caller // must not call waitForLogs. func (r *Runtime) startStep(step *backend_types.Step) (func(), int64, error) { if err := r.engine.StartStep(r.ctx, step, r.taskUUID); err != nil { return nil, 0, err } startTime := time.Now().Unix() rc, err := r.engine.TailStep(r.ctx, step, r.taskUUID) if err != nil { return nil, 0, err } var wg sync.WaitGroup wg.Go(func() { logger := r.makeLogger() if err := r.logger(step, rc); err != nil { logger.Error().Err(err).Str("step", step.Name).Msg("step log streaming failed") } _ = rc.Close() }) return wg.Wait, startTime, nil } // completeStep drains the log stream, waits for the process to exit, destroys // the container, and maps exit conditions (OOM kill, non-zero exit code, context // cancellation) to typed errors. // // The runnerCtx is intentionally used for DestroyStep so that container cleanup can // still reach the backend even after the workflow context (r.ctx) is canceled. func (r *Runtime) completeStep(runnerCtx context.Context, step *backend_types.Step, waitForLogs func(), startTime int64) (*backend_types.State, error) { // Drain the log stream before waiting on the process exit. waitForLogs() waitState, err := r.engine.WaitStep(r.ctx, step, r.taskUUID) //nolint:contextcheck if err != nil { if errors.Is(err, context.Canceled) { if waitState == nil { waitState = &backend_types.State{} } waitState.Error = pipeline_errors.ErrCancel } else { return nil, err } } // Use runnerCtx here: the workflow context may already be canceled but we // still need to reach the backend to stop/remove the container. if err := r.engine.DestroyStep(runnerCtx, step, r.taskUUID); err != nil { return nil, err } waitState.Started = startTime // Re-check context cancellation: the wait may have raced with cancellation. if ctxErr := r.ctx.Err(); ctxErr != nil && errors.Is(ctxErr, context.Canceled) { waitState.Error = pipeline_errors.ErrCancel } if waitState.OOMKilled { return waitState, &pipeline_errors.OomError{ UUID: step.UUID, Code: waitState.ExitCode, } } if waitState.ExitCode != 0 { return waitState, &pipeline_errors.ExitError{ UUID: step.UUID, Code: waitState.ExitCode, } } return waitState, nil } // runBlockingStep starts the step and blocks until it fully completes. // The error is traced and returned to runStage, which feeds it into the // stage error group. func (r *Runtime) runBlockingStep(runnerCtx context.Context, step *backend_types.Step) error { logger := r.makeLogger() waitForLogs, startTime, err := r.startStep(step) if err != nil { // The step never ran — trace the start failure and surface it. return r.traceStep(nil, err, step) } processState, err := r.completeStep(runnerCtx, step, waitForLogs, startTime) logger.Debug().Str("step", step.Name).Msg("complete") if errors.Is(err, context.Canceled) { err = pipeline_errors.ErrCancel } err = r.traceStep(processState, err, step) if err != nil && metadata.Failure(step.Failure) == metadata.FailureIgnore { return nil } return err } // runDetachedStep starts the step and returns as soon as the container is running // and log streaming is set up. The rest of the step lifecycle runs in the background. // // Any error that occurs after setup is logged but not propagated — it cannot // influence the pipeline outcome at that point. func (r *Runtime) runDetachedStep(runnerCtx context.Context, step *backend_types.Step) error { waitForLogs, startTime, err := r.startStep(step) if err != nil { // Setup failed before the container was running — treat it like a // blocking failure so the pipeline is aware. return r.traceStep(nil, err, step) } // Container is up and logging is streaming — hand off to background. r.uploadWait.Add(1) go func() { defer r.uploadWait.Done() logger := r.makeLogger() processState, err := r.completeStep(runnerCtx, step, waitForLogs, startTime) logger.Debug().Str("step", step.Name).Msg("complete") if errors.Is(err, context.Canceled) { err = pipeline_errors.ErrCancel } if err != nil { logger.Error().Err(err).Str("step", step.Name).Msg("detached step failed while running") } if traceErr := r.traceStep(processState, err, step); traceErr != nil { logger.Error().Err(traceErr).Str("step", step.Name).Msg("failed to trace detached step result") } }() return nil } // traceStep reports the current state of a step to the tracer. // // - processState == nil, err == nil → step is being marked as started // - processState == nil, err != nil → step failed to start // - processState != nil → step has finished (err may or may not be set) // // Always returns err unchanged so callers can write: return r.traceStep(state, err, step). func (r *Runtime) traceStep(processState *backend_types.State, err error, step *backend_types.Step) error { s := new(state.State) s.Workflow.Started = r.started s.CurrStep = step s.Workflow.Error = r.err.Get() switch { case processState == nil && err != nil: // Step failed to start — create an dummy exited process state. s.CurrStepState = backend_types.State{ Error: err, Exited: true, OOMKilled: false, } case processState != nil: s.CurrStepState = *processState // processState == nil && err == nil: step just started, leave s.CurrStepState zero-valued. } if traceErr := r.tracer.Trace(s); traceErr != nil { logger := r.makeLogger() logger.Error().Err(traceErr).Msg("could not trace step state change") } return err } ================================================ FILE: pipeline/runtime/step_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package runtime import ( "context" "errors" "io" "strings" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types/mocks" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/logging" tracer_mocks "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks" ) const testWorkflowID = "WID_test" // newDummyRuntime creates a Runtime backed by the dummy engine with a pre-setup // workflow so individual step methods can be tested in isolation. func newDummyRuntime(t *testing.T, tracer *tracer_mocks.MockTracer) *Runtime { t.Helper() engine := dummy.New() r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithTaskUUID(testWorkflowID), WithLogger(newTestLogger(t)), ) require.NoError(t, engine.SetupWorkflow(t.Context(), nil, testWorkflowID)) return r } func dummyStep(name string) *backend_types.Step { return &backend_types.Step{ Name: name, UUID: name + "-uuid", Type: backend_types.StepTypeCommands, OnSuccess: true, OnFailure: false, Environment: map[string]string{}, Commands: []string{"echo hello"}, } } func TestShouldSkipStep(t *testing.T) { t.Parallel() t.Run("NoErrorOnSuccessTrue", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := &backend_types.Step{Name: "s", OnSuccess: true, OnFailure: false} assert.False(t, r.shouldSkipStep(step)) }) t.Run("NoErrorOnSuccessFalse", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := &backend_types.Step{Name: "s", OnSuccess: false, OnFailure: true} assert.True(t, r.shouldSkipStep(step)) }) t.Run("ErrorOnFailureTrue", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) r.err.Set(errors.New("previous failure")) step := &backend_types.Step{Name: "s", OnSuccess: false, OnFailure: true} assert.False(t, r.shouldSkipStep(step)) }) t.Run("ErrorOnFailureFalse", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) r.err.Set(errors.New("previous failure")) step := &backend_types.Step{Name: "s", OnSuccess: true, OnFailure: false} assert.True(t, r.shouldSkipStep(step)) }) } func TestTraceStep(t *testing.T) { t.Parallel() t.Run("StepStarted", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) r.started = 1000 step := dummyStep("s1") err := r.traceStep(nil, nil, step) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.Equal(t, int64(1000), calls[0].Workflow.Started) assert.Equal(t, step, calls[0].CurrStep) assert.False(t, calls[0].CurrStepState.Exited) }) t.Run("StepFailedToStart", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := dummyStep("s1") startErr := errors.New("image pull failed") err := r.traceStep(nil, startErr, step) assert.ErrorIs(t, err, startErr) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.True(t, calls[0].CurrStepState.Exited) assert.Equal(t, startErr, calls[0].CurrStepState.Error) }) t.Run("StepFinished", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := dummyStep("s1") ps := &backend_types.State{Exited: true, ExitCode: 0, Started: 42} err := r.traceStep(ps, nil, step) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.True(t, calls[0].CurrStepState.Exited) assert.Equal(t, 0, calls[0].CurrStepState.ExitCode) assert.Equal(t, int64(42), calls[0].CurrStepState.Started) }) t.Run("StepSkipped", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := dummyStep("s1") ps := &backend_types.State{Exited: true, Skipped: true} err := r.traceStep(ps, nil, step) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.True(t, calls[0].CurrStepState.Skipped) assert.True(t, calls[0].CurrStepState.Exited) }) t.Run("PipelineErrorPropagated", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) r.err.Set(errors.New("earlier failure")) _ = r.traceStep(nil, nil, dummyStep("s1")) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.EqualError(t, calls[0].Workflow.Error, "earlier failure") }) } // The startStep uses dummy for success + start/tail failures and mockery mock for logger test. func TestStartStep(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := dummyStep("s1") waitForLogs, startTime, err := r.startStep(step) assert.NoError(t, err) assert.NotNil(t, waitForLogs) assert.Greater(t, startTime, int64(0)) waitForLogs() }) t.Run("StartStepError", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := dummyStep("fail") step.Environment[dummy.EnvKeyStepStartFail] = "true" _, _, err := r.startStep(step) assert.Error(t, err) }) t.Run("TailStepError", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := dummyStep("tail-fail") step.Environment[dummy.EnvKeyStepTailFail] = "true" r.logger = logging.Logger(func(_ *backend_types.Step, _ io.ReadCloser) error { return nil }) _, _, err := r.startStep(step) assert.Error(t, err) }) t.Run("WithLogger", func(t *testing.T) { t.Parallel() var logCalled int32 engine := mocks.NewMockBackend(t) engine.On("StartStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) engine.On("TailStep", mock.Anything, mock.Anything, mock.Anything). Return(io.NopCloser(strings.NewReader("log line")), nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(logging.Logger(func(_ *backend_types.Step, rc io.ReadCloser) error { atomic.AddInt32(&logCalled, 1) _, _ = io.ReadAll(rc) return nil }))) step := dummyStep("s1") waitForLogs, _, err := r.startStep(step) require.NoError(t, err) waitForLogs() assert.Equal(t, int32(1), atomic.LoadInt32(&logCalled)) }) t.Run("LoggerError", func(t *testing.T) { t.Parallel() logErr := errors.New("log stream broken") engine := mocks.NewMockBackend(t) engine.On("StartStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) engine.On("TailStep", mock.Anything, mock.Anything, mock.Anything). Return(io.NopCloser(strings.NewReader("data")), nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(logging.Logger(func(_ *backend_types.Step, rc io.ReadCloser) error { _, _ = io.ReadAll(rc) return logErr // triggers the error-log branch in the goroutine })), ) waitForLogs, _, err := r.startStep(dummyStep("s1")) require.NoError(t, err) // startStep itself succeeds // waitForLogs blocks until the goroutine finishes; the branch is hit inside. waitForLogs() }) } // The completeStep uses mockery mock for fine-grained control over // WaitStep/DestroyStep return values that dummy cannot provide. func TestCompleteStep(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) assert.NoError(t, err) assert.True(t, ws.Exited) assert.Equal(t, 0, ws.ExitCode) }) t.Run("NonZeroExitCode", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 1}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) var exitErr *pipeline_errors.ExitError assert.True(t, errors.As(err, &exitErr)) assert.Equal(t, 1, exitErr.Code) assert.Equal(t, 1, ws.ExitCode) }) t.Run("OOMKilled", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, OOMKilled: true, ExitCode: 137}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) var oomErr *pipeline_errors.OomError assert.True(t, errors.As(err, &oomErr)) assert.True(t, ws.OOMKilled) }) t.Run("ContextCanceledNilState", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(nil, context.Canceled) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) assert.NoError(t, err) require.NotNil(t, ws, "nil guard must allocate a new State") assert.Equal(t, pipeline_errors.ErrCancel, ws.Error) }) t.Run("ContextCanceledWithState", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, context.Canceled) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) assert.NoError(t, err) assert.Equal(t, pipeline_errors.ErrCancel, ws.Error) }) t.Run("WaitStepNonCancelError", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(nil, errors.New("engine exploded")) // DestroyStep should NOT be called — early return. r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) assert.EqualError(t, err, "engine exploded") assert.Nil(t, ws) }) t.Run("DestroyStepError", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything). Return(errors.New("cleanup failed")) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) assert.EqualError(t, err, "cleanup failed") assert.Nil(t, ws) }) t.Run("SetsStartTime", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, 9999) assert.NoError(t, err) assert.Equal(t, int64(9999), ws.Started) }) t.Run("CtxCanceledAfterDestroyStep", func(t *testing.T) { t.Parallel() // WaitStep succeeds (no context.Canceled from the engine), // but r.ctx is already canceled — the re-check at the bottom catches it. canceledCtx, cancel := context.WithCancelCause(context.Background()) cancel(nil) // pre-cancel engine := mocks.NewMockBackend(t) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), WithContext(canceledCtx), // r.ctx is canceled ) ws, err := r.completeStep(t.Context(), dummyStep("s1"), func() {}, time.Now().Unix()) assert.NoError(t, err) require.NotNil(t, ws) assert.Equal(t, pipeline_errors.ErrCancel, ws.Error, "re-check should set ErrCancel when r.ctx is already canceled") }) } // The executeStep uses dummy for the full step lifecycle. func TestExecuteStep(t *testing.T) { t.Parallel() t.Run("SkippedStepTraced", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := &backend_types.Step{ Name: "skip-me", UUID: "skip-uuid", Type: backend_types.StepTypeCommands, Environment: map[string]string{}, OnSuccess: false, OnFailure: true, } err := r.executeStep(t.Context(), step) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.True(t, calls[0].CurrStepState.Skipped) }) t.Run("BlockingStepSuccess", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := dummyStep("build") err := r.executeStep(t.Context(), step) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 2) assert.False(t, calls[0].CurrStepState.Exited, "first trace should be step-started") assert.True(t, calls[1].CurrStepState.Exited, "second trace should be step-completed") }) t.Run("BlockingStepFailure", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := dummyStep("fail") step.Environment[dummy.EnvKeyStepExitCode] = "1" err := r.executeStep(t.Context(), step) assert.Error(t, err) var exitErr *pipeline_errors.ExitError assert.True(t, errors.As(err, &exitErr)) assert.Equal(t, 1, exitErr.Code) }) // Use an atomic counter instead of getTracerStates inside Eventually to avoid // a data race: the detached-step goroutine writes to mock.Calls concurrently // with the Eventually polling goroutine reading it. t.Run("DetachedStep", func(t *testing.T) { t.Parallel() var traced int32 tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything). Run(func(mock.Arguments) { atomic.AddInt32(&traced, 1) }). Return(nil).Maybe() r := newDummyRuntime(t, tracer) step := dummyStep("svc") step.Detached = true step.Type = backend_types.StepTypeService step.Environment[dummy.EnvKeyStepSleep] = "1ms" err := r.executeStep(t.Context(), step) assert.NoError(t, err) assert.Eventually(t, func() bool { return atomic.LoadInt32(&traced) >= 2 }, time.Second, 10*time.Millisecond) }) } func TestRunBlockingStep(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) err := r.runBlockingStep(t.Context(), dummyStep("s1")) assert.NoError(t, err) }) t.Run("FailureIgnore", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := dummyStep("s1") step.Failure = string(metadata.FailureIgnore) step.Environment[dummy.EnvKeyStepExitCode] = "1" err := r.runBlockingStep(t.Context(), step) assert.NoError(t, err, "error should be suppressed when Failure==FailureIgnore") }) t.Run("StartFailure", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) step := dummyStep("s1") step.Environment[dummy.EnvKeyStepStartFail] = "true" err := r.runBlockingStep(t.Context(), step) assert.Error(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.True(t, calls[0].CurrStepState.Exited) }) t.Run("DestroyStepErrorMappedToErrCancel", func(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("StartStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything). Return(context.Canceled) engine.On("TailStep", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("")), nil) tracer := newTestTracer(t) r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t))) err := r.runBlockingStep(t.Context(), dummyStep("s1")) assert.ErrorIs(t, err, pipeline_errors.ErrCancel) }) } func TestRunDetachedStep(t *testing.T) { t.Parallel() // Use an atomic counter instead of getTracerStates inside Eventually to avoid // a data race: the detached-step goroutine writes to mock.Calls concurrently // with the Eventually polling goroutine reading it. t.Run("ReturnsImmediately", func(t *testing.T) { t.Parallel() var traced int32 tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything). Run(func(mock.Arguments) { atomic.AddInt32(&traced, 1) }). Return(nil).Maybe() r := newDummyRuntime(t, tracer) step := dummyStep("svc") step.Environment[dummy.EnvKeyStepSleep] = "1ms" err := r.runDetachedStep(t.Context(), step) assert.NoError(t, err) assert.Eventually(t, func() bool { return atomic.LoadInt32(&traced) >= 1 }, time.Second, 10*time.Millisecond) }) t.Run("StartFailure", func(t *testing.T) { t.Parallel() r := newDummyRuntime(t, newTestTracer(t)) step := dummyStep("svc") step.Environment[dummy.EnvKeyStepStartFail] = "true" err := r.runDetachedStep(t.Context(), step) assert.Error(t, err) }) // Branch 1: context.Canceled from WaitStep → mapped to ErrCancel in the goroutine. // Branch 2: non-nil error from completeStep → error log branch. // Both are covered by a WaitStep that returns context.Canceled. // // Use an atomic counter instead of getTracerStates inside Eventually to avoid // a data race: the detached-step goroutine writes to mock.Calls concurrently // with the Eventually polling goroutine reading it. t.Run("BackgroundContextCanceled", func(t *testing.T) { t.Parallel() var traced int32 tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything). Run(func(mock.Arguments) { atomic.AddInt32(&traced, 1) }). Return(nil).Maybe() engine := mocks.NewMockBackend(t) engine.On("StartStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) engine.On("TailStep", mock.Anything, mock.Anything, mock.Anything). Return(io.NopCloser(strings.NewReader("")), nil) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(nil, context.Canceled) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t)), ) step := dummyStep("svc") err := r.runDetachedStep(t.Context(), step) assert.NoError(t, err) // returns immediately // Wait for the goroutine to finish and emit its trace. assert.Eventually(t, func() bool { return atomic.LoadInt32(&traced) >= 1 }, time.Second, 10*time.Millisecond) }) // Branch 3: traceStep itself fails inside the goroutine → trace-error log branch. t.Run("BackgroundTracerError", func(t *testing.T) { t.Parallel() traceErr := errors.New("trace failed in background") engine := mocks.NewMockBackend(t) engine.On("StartStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) engine.On("TailStep", mock.Anything, mock.Anything, mock.Anything). Return(io.NopCloser(strings.NewReader("")), nil) engine.On("WaitStep", mock.Anything, mock.Anything, mock.Anything). Return(&backend_types.State{Exited: true, ExitCode: 0}, nil) engine.On("DestroyStep", mock.Anything, mock.Anything, mock.Anything).Return(nil) var traced int32 tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything). Run(func(_ mock.Arguments) { atomic.AddInt32(&traced, 1) }). Return(traceErr) // every Trace call fails r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.runDetachedStep(t.Context(), dummyStep("svc")) assert.NoError(t, err) assert.Eventually(t, func() bool { return atomic.LoadInt32(&traced) >= 1 }, time.Second, 10*time.Millisecond) }) } ================================================ FILE: pipeline/runtime/workflow.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runtime import ( "context" "errors" "fmt" "strings" "sync" "time" "golang.org/x/sync/errgroup" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" ) // Run starts the workflow, executes all stages sequentially, and tears down the // workflow on exit. The runnerCtx must outlive workflow cancellation so that cleanup // can still reach the backend (e.g. stopping Docker containers). func (r *Runtime) Run(runnerCtx context.Context) error { if err := r.validateConfig(); err != nil { return err } logger := r.makeLogger() r.logStages() destroyWorkflowFunc := sync.OnceFunc(func() { ctx := runnerCtx //nolint:contextcheck if ctx.Err() != nil { // runnerCtx itself is done — fall back to a short-lived shutdown context. ctx = GetShutdownCtx() } if err := r.engine.DestroyWorkflow(ctx, r.spec, r.taskUUID); err != nil { logger.Error().Err(err).Msg("could not destroy workflow") } }) // we make sure cleanup always happens defer destroyWorkflowFunc() r.started = time.Now().Unix() if err := r.engine.SetupWorkflow(r.ctx, r.spec, r.taskUUID); err != nil { //nolint:contextcheck r.traceWorkflowSetupError(err) return err } for _, stage := range r.spec.Stages { stageChan := r.runStage(runnerCtx, stage.Steps) select { case <-r.ctx.Done(): <-stageChan return pipeline_errors.ErrCancel case err := <-stageChan: if err != nil { r.err.Set(err) } } } // Now we can shutdown the workflow destroyWorkflowFunc() // Ensure all logs/traces are uploaded before finishing logger.Debug().Msg("waiting for logs and traces upload") r.uploadWait.Wait() logger.Debug().Msg("logs and traces uploaded") return r.err.Get() } // The validateConfig checks if a dev made a mistake, // this should be values a user has no control over. func (r *Runtime) validateConfig() error { if r.tracer == nil { return fmt.Errorf("runtime misconfiguration: tracer must not be nil") } if r.logger == nil { return fmt.Errorf("runtime misconfiguration: logger must not be nil") } if r.spec == nil { return fmt.Errorf("runtime misconfiguration: backend configuration is missing") } return nil } // logStages logs the ordered list of stages and their steps at debug level. func (r *Runtime) logStages() { logger := r.makeLogger() logger.Debug().Msgf("executing %d stages, in order of:", len(r.spec.Stages)) for stagePos, stage := range r.spec.Stages { stepNames := make([]string, 0, len(stage.Steps)) for _, step := range stage.Steps { stepNames = append(stepNames, step.Name) } logger.Debug(). Int("StagePos", stagePos). Str("Steps", strings.Join(stepNames, ",")). Msg("stage") } } // traceWorkflowSetupError traces an ErrInvalidWorkflowSetup to the tracer. func (r *Runtime) traceWorkflowSetupError(err error) { var stepErr *pipeline_errors.ErrInvalidWorkflowSetup if !errors.As(err, &stepErr) { return } s := new(state.State) s.CurrStep = stepErr.Step s.Workflow.Error = stepErr.Err s.CurrStepState = backend_types.State{ Error: stepErr.Err, Exited: true, ExitCode: 1, } if traceErr := r.tracer.Trace(s); traceErr != nil { logger := r.makeLogger() logger.Error().Err(traceErr).Msg("failed to trace workflow setup error") } } // runStage executes all steps of a stage in parallel. // It returns a channel that emits the combined error (if any) once all steps finish. func (r *Runtime) runStage(runnerCtx context.Context, steps []*backend_types.Step) <-chan error { var g errgroup.Group done := make(chan error) for _, step := range steps { g.Go(func() error { return r.executeStep(runnerCtx, step) }) } go func() { done <- g.Wait() close(done) }() return done } ================================================ FILE: pipeline/runtime/workflow_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build test package runtime import ( "context" "errors" "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/dummy" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types/mocks" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" tracer_mocks "go.woodpecker-ci.org/woodpecker/v3/pipeline/tracing/mocks" ) func TestRunNilTracer(t *testing.T) { t.Parallel() r := New(&backend_types.Config{}, dummy.New(), WithLogger(newTestLogger(t)), WithTracer(nil)) err := r.Run(t.Context()) assert.Error(t, err) assert.Contains(t, err.Error(), "tracer must not be nil") } func TestRunSuccess(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{{ Steps: []*backend_types.Step{{ Name: "build", UUID: "u1", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo hello"}, }}, }}, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 2) } func TestRunMultipleStages(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{ {Steps: []*backend_types.Step{{ Name: "stage1", UUID: "u1", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo 1"}, }}}, {Steps: []*backend_types.Step{{ Name: "stage2", UUID: "u2", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo 2"}, }}}, }, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.NoError(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 4) } func TestRunStepError(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{{ Steps: []*backend_types.Step{{ Name: "fail", UUID: "u1", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{dummy.EnvKeyStepExitCode: "1"}, Commands: []string{"exit 1"}, }}, }}, }, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) var exitErr *pipeline_errors.ExitError assert.True(t, errors.As(err, &exitErr)) assert.Equal(t, 1, exitErr.Code) } func TestRunContextCanceled(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancelCause(t.Context()) cancel(nil) r := New( &backend_types.Config{ Stages: []*backend_types.Stage{{ Steps: []*backend_types.Step{{ Name: "s1", UUID: "u1", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo hello"}, }}, }}, }, dummy.New(), WithTracer(newTestTracer(t)), WithContext(ctx), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.ErrorIs(t, err, pipeline_errors.ErrCancel) } func TestRunSetupWorkflowError(t *testing.T) { t.Parallel() r := New( &backend_types.Config{}, dummy.New(), WithTracer(newTestTracer(t)), WithTaskUUID(dummy.WorkflowSetupFailUUID), WithLogger(newTestLogger(t)), ) err := r.Run(t.Context()) assert.Error(t, err) } func TestRunSetupWorkflowInvalidSetupError(t *testing.T) { t.Parallel() tracer := newTestTracer(t) step := &backend_types.Step{Name: "clone", UUID: "clone-uuid"} setupErr := &pipeline_errors.ErrInvalidWorkflowSetup{ Err: errors.New("bad image"), Step: step, } engine := mocks.NewMockBackend(t) engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(setupErr) engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithLogger(newTestLogger(t))) err := r.Run(t.Context()) assert.Error(t, err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.Equal(t, step, calls[0].CurrStep) assert.True(t, calls[0].CurrStepState.Exited) assert.Equal(t, 1, calls[0].CurrStepState.ExitCode) } func TestRunDestroyWorkflowAlwaysCalled(t *testing.T) { t.Parallel() var destroyed int32 engine := mocks.NewMockBackend(t) engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil) engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything). Run(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) _ = r.Run(t.Context()) assert.Equal(t, int32(1), atomic.LoadInt32(&destroyed)) } func TestRunDestroyWorkflowCalledOnSetupError(t *testing.T) { t.Parallel() var destroyed int32 engine := mocks.NewMockBackend(t) engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything). Return(errors.New("setup boom")) engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything). Run(func(_ mock.Arguments) { atomic.AddInt32(&destroyed, 1) }).Return(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t))) _ = r.Run(t.Context()) assert.Equal(t, int32(1), atomic.LoadInt32(&destroyed)) } func TestTraceWorkflowSetupError(t *testing.T) { t.Parallel() t.Run("MatchingError", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t))) step := &backend_types.Step{Name: "setup", UUID: "su"} err := &pipeline_errors.ErrInvalidWorkflowSetup{Err: errors.New("bad"), Step: step} r.traceWorkflowSetupError(err) calls := getTracerStates(tracer) require.Len(t, calls, 1) assert.Equal(t, step, calls[0].CurrStep) assert.True(t, calls[0].CurrStepState.Exited) assert.Equal(t, 1, calls[0].CurrStepState.ExitCode) }) t.Run("NonMatchingError", func(t *testing.T) { t.Parallel() tracer := tracer_mocks.NewMockTracer(t) // Trace should NOT be called — no .On() setup means test panics if called. r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t))) r.traceWorkflowSetupError(errors.New("generic error")) }) t.Run("TracerFailure", func(t *testing.T) { t.Parallel() tracer := tracer_mocks.NewMockTracer(t) tracer.On("Trace", mock.Anything).Return(errors.New("trace failed")) r := New(&backend_types.Config{}, dummy.New(), WithTracer(tracer), WithLogger(newTestLogger(t))) step := &backend_types.Step{Name: "setup", UUID: "su"} // Should not panic — the error is logged, not returned. r.traceWorkflowSetupError(&pipeline_errors.ErrInvalidWorkflowSetup{ Err: errors.New("bad"), Step: step, }) }) } func TestRunStage(t *testing.T) { t.Parallel() t.Run("ParallelExecution", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) steps := []*backend_types.Step{ {Name: "a", UUID: "ua", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo a"}}, {Name: "b", UUID: "ub", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo b"}}, {Name: "c", UUID: "uc", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo c"}}, } err := <-r.runStage(t.Context(), steps) assert.NoError(t, err) assert.Len(t, getTracerStates(tracer), 6) }) t.Run("OneStepFails", func(t *testing.T) { t.Parallel() tracer := newTestTracer(t) r := newDummyRuntime(t, tracer) steps := []*backend_types.Step{ {Name: "good", UUID: "ug", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{}, Commands: []string{"echo ok"}}, {Name: "bad", UUID: "ub", Type: backend_types.StepTypeCommands, OnSuccess: true, Environment: map[string]string{dummy.EnvKeyStepExitCode: "1"}, Commands: []string{"exit 1"}}, } err := <-r.runStage(t.Context(), steps) assert.Error(t, err) }) } func TestNewDefaults(t *testing.T) { t.Parallel() spec := &backend_types.Config{} r := New(spec, dummy.New()) assert.Equal(t, spec, r.spec) assert.NotEmpty(t, r.taskUUID) assert.NotNil(t, r.ctx) assert.NotNil(t, r.tracer) assert.NotNil(t, r.engine) assert.NoError(t, r.err.Get()) } func TestWithOptions(t *testing.T) { t.Parallel() engine := dummy.New() tracer := newTestTracer(t) ctx := context.Background() desc := map[string]string{"repo": "test"} r := New(&backend_types.Config{}, engine, WithTracer(tracer), WithContext(ctx), WithDescription(desc), WithTaskUUID("custom-uuid"), WithLogger(newTestLogger(t)), ) assert.Equal(t, engine, r.engine) assert.Equal(t, tracer, r.tracer) assert.Equal(t, ctx, r.ctx) assert.Equal(t, "custom-uuid", r.taskUUID) assert.Equal(t, "test", r.description["repo"]) } func TestGetShutdownCtx(t *testing.T) { ctx := GetShutdownCtx() assert.NotNil(t, ctx) ctx2 := GetShutdownCtx() assert.Equal(t, ctx, ctx2) } // Gap A: logger == nil guard. func TestRunNilLogger(t *testing.T) { t.Parallel() r := New(&backend_types.Config{}, dummy.New(), WithTracer(newTestTracer(t)), // WithLogger intentionally omitted ) err := r.Run(t.Context()) assert.Error(t, err) assert.Contains(t, err.Error(), "logger must not be nil") } // Gap B: runnerCtx is already done inside the defer → GetShutdownCtx() fallback. func TestRunDestroyWorkflowFallsBackToShutdownCtx(t *testing.T) { t.Parallel() engine := mocks.NewMockBackend(t) engine.On("SetupWorkflow", mock.Anything, mock.Anything, mock.Anything).Return(nil) var destroyCtx context.Context engine.On("DestroyWorkflow", mock.Anything, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { destroyCtx, _ = args.Get(0).(context.Context) }).Return(nil) // Pass a pre-canceled runnerCtx so ctx.Err() != nil in the defer. runnerCtx, cancel := context.WithCancelCause(context.Background()) cancel(nil) r := New(&backend_types.Config{}, engine, WithTracer(newTestTracer(t)), WithLogger(newTestLogger(t)), ) _ = r.Run(runnerCtx) require.NotNil(t, destroyCtx) // The shutdown context is not the canceled runnerCtx — it must still be valid // (or at least not the same canceled one). assert.NotEqual(t, runnerCtx, destroyCtx, "DestroyWorkflow should receive the shutdown fallback context, not the canceled runnerCtx") } ================================================ FILE: pipeline/shared/replace_secrets.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package shared import "strings" // NewSecretsReplacer creates a new strings.Replacer to replace sensitive // strings with asterisks. It takes a slice of secrets strings as input // and returns a populated strings.Replacer that will replace those // secrets with asterisks. Each secret string is split on newlines to // handle multi-line secrets. func NewSecretsReplacer(secrets []string) *strings.Replacer { var oldNew []string // Strings shorter than minStringLength are not considered secrets. // Do not sanitize them. const minStringLength = 3 for _, old := range secrets { old = strings.TrimSpace(old) if len(old) <= minStringLength { continue } // since replacer is executed on each line we have to split multi-line-secrets for _, part := range strings.Split(old, "\n") { if len(part) == 0 { continue } oldNew = append(oldNew, part) oldNew = append(oldNew, "********") } } return strings.NewReplacer(oldNew...) } ================================================ FILE: pipeline/shared/replace_secrets_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package shared import ( "testing" "github.com/stretchr/testify/assert" ) func TestNewSecretsReplacer(t *testing.T) { tc := []struct { name string log string secrets []string expect string }{{ name: "dont replace secrets with less than 3 chars", log: "start log\ndone", secrets: []string{"", "d", "art"}, expect: "start log\ndone", }, { name: "single line passwords", log: `this IS secret: password`, secrets: []string{"password", " IS "}, expect: `this IS secret: ********`, }, { name: "secret with one newline", log: "start log\ndone\nnow\nan\nmulti line secret!! ;)", secrets: []string{"an\nmulti line secret!!"}, expect: "start log\ndone\nnow\n********\n******** ;)", }, { name: "secret with multiple lines with no match", log: "start log\ndone\nnow\nan\nmulti line secret!! ;)", secrets: []string{"Test\nwith\n\ntwo new lines"}, expect: "start log\ndone\nnow\nan\nmulti line secret!! ;)", }, { name: "secret with multiple lines with match", log: "start log\ndone\nnow\nan\nmulti line secret!! ;)\nwith\ntwo\n\nnewlines", secrets: []string{"an\nmulti line secret!!", "two\n\nnewlines"}, expect: "start log\ndone\nnow\n********\n******** ;)\nwith\n********\n\n********", }} for _, c := range tc { t.Run(c.name, func(t *testing.T) { rep := NewSecretsReplacer(c.secrets) result := rep.Replace(c.log) assert.EqualValues(t, c.expect, result) }) } } ================================================ FILE: pipeline/state/state.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package state import ( backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) // State is used to signal the current workflow and step state. // Only steps using the trace func report back what's going on. // And the workflow is updated alongside it. type State struct { // Global state of the currently running Workflow. Workflow Workflow // Current step that updates the step and workflow state CurrStep *backend_types.Step `json:"step"` // Current step state CurrStepState backend_types.State } type Workflow struct { // Workflow start time Started int64 `json:"time"` // Current workflow error state Error error `json:"error"` } ================================================ FILE: pipeline/tracing/mocks/mock_Tracer.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" ) // NewMockTracer creates a new instance of MockTracer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockTracer(t interface { mock.TestingT Cleanup(func()) }) *MockTracer { mock := &MockTracer{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockTracer is an autogenerated mock type for the Tracer type type MockTracer struct { mock.Mock } type MockTracer_Expecter struct { mock *mock.Mock } func (_m *MockTracer) EXPECT() *MockTracer_Expecter { return &MockTracer_Expecter{mock: &_m.Mock} } // Trace provides a mock function for the type MockTracer func (_mock *MockTracer) Trace(state1 *state.State) error { ret := _mock.Called(state1) if len(ret) == 0 { panic("no return value specified for Trace") } var r0 error if returnFunc, ok := ret.Get(0).(func(*state.State) error); ok { r0 = returnFunc(state1) } else { r0 = ret.Error(0) } return r0 } // MockTracer_Trace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Trace' type MockTracer_Trace_Call struct { *mock.Call } // Trace is a helper method to define mock.On call // - state1 *state.State func (_e *MockTracer_Expecter) Trace(state1 interface{}) *MockTracer_Trace_Call { return &MockTracer_Trace_Call{Call: _e.mock.On("Trace", state1)} } func (_c *MockTracer_Trace_Call) Run(run func(state1 *state.State)) *MockTracer_Trace_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *state.State if args[0] != nil { arg0 = args[0].(*state.State) } run( arg0, ) }) return _c } func (_c *MockTracer_Trace_Call) Return(err error) *MockTracer_Trace_Call { _c.Call.Return(err) return _c } func (_c *MockTracer_Trace_Call) RunAndReturn(run func(state1 *state.State) error) *MockTracer_Trace_Call { _c.Call.Return(run) return _c } ================================================ FILE: pipeline/tracing/tracer.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package tracing import ( "go.woodpecker-ci.org/woodpecker/v3/pipeline/state" ) // Tracer handles process tracing. type Tracer interface { Trace(*state.State) error } // TraceFunc type is an adapter to allow the use of ordinary // functions as a Tracer. type TraceFunc func(*state.State) error // Trace calls f(state). func (f TraceFunc) Trace(state *state.State) error { return f(state) } // NoOpTracer provides a tracer that does nothing. var NoOpTracer = TraceFunc(func(*state.State) error { return nil }) ================================================ FILE: pipeline/utils/copy_line_by_line.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "bufio" "bytes" "errors" "io" ) func writeChunks(dst io.Writer, data []byte, size int) error { if len(data) <= size { _, err := dst.Write(data) return err } for len(data) > size { if _, err := dst.Write(data[:size]); err != nil { return err } data = data[size:] } if len(data) > 0 { _, err := dst.Write(data) return err } return nil } func CopyLineByLine(dst io.Writer, src io.Reader, maxSize int) error { r := bufio.NewReader(src) // buffer to cache var buf []byte // buffer to read readBuf := make([]byte, maxSize) for { n, err := r.Read(readBuf) // handle the data first if n > 0 { // if it has data, cache into the buffer buf = append(buf, readBuf[:n]...) processBuffer: for len(buf) > 0 { // find the index to anchor the new line idx := bytes.IndexByte(buf, '\n') switch { case idx >= 0: // found the new line, write to the dst lineEnd := idx + 1 if lineEnd > maxSize { if wErr := writeChunks(dst, buf[:lineEnd], maxSize); wErr != nil { return wErr } } else { if _, wErr := dst.Write(buf[:lineEnd]); wErr != nil { return wErr } } // remove the line written from the buffer buf = buf[lineEnd:] case len(buf) >= maxSize: if _, wErr := dst.Write(buf[:maxSize]); wErr != nil { return wErr } buf = buf[maxSize:] default: // no newline found and buffer not full, read more data break processBuffer } } } // and then if it is EOF, write the remaining data and break the loop if errors.Is(err, io.EOF) { if len(buf) == 0 { break } if _, wErr := dst.Write(buf); wErr != nil { return wErr } break } if err != nil { return err } } return nil } ================================================ FILE: pipeline/utils/copy_line_by_line_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils_test import ( "io" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline/utils" ) type testWriter struct { *sync.Mutex writes []string } func (b *testWriter) Write(p []byte) (n int, err error) { b.Lock() defer b.Unlock() b.writes = append(b.writes, string(p)) return len(p), nil } func (b *testWriter) Close() error { return nil } func (b *testWriter) GetWrites() []string { b.Lock() defer b.Unlock() w := make([]string, len(b.writes)) copy(w, b.writes) return w } func TestCopyLineByLine(t *testing.T) { r, w := io.Pipe() testWriter := &testWriter{ Mutex: &sync.Mutex{}, writes: make([]string, 0), } done := make(chan struct{}) go func() { err := utils.CopyLineByLine(testWriter, r, 1024) assert.NoError(t, err) close(done) }() // write 4 bytes without newline if _, err := w.Write([]byte("1234")); err != nil { t.Fatalf("unexpected error: %v", err) } // wait until no writes have occurred (should be immediate) assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == 0 }, time.Second, 5*time.Millisecond, "expected 0 writes after first write") // write more bytes with newlines if _, err := w.Write([]byte("5\n678\n90")); err != nil { t.Fatalf("unexpected error: %v", err) } // wait until two writes have occurred assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == 2 }, time.Second, 5*time.Millisecond, "expected 2 writes after second write") writes := testWriter.GetWrites() writtenData := strings.Join(writes, "-") assert.Equal(t, "12345\n-678\n", writtenData, "unexpected writtenData: %s", writtenData) // closing the writer should flush the remaining data w.Close() // wait for the goroutine to finish select { case <-done: case <-time.After(time.Second): t.Fatal("timeout waiting for goroutine to finish") } // the written data contains all the data we wrote writtenData = strings.Join(testWriter.GetWrites(), "-") assert.Equal(t, "12345\n-678\n-90", writtenData, "unexpected writtenData: %s", writtenData) } func TestCopyLineByLineSizeLimit(t *testing.T) { r, w := io.Pipe() testWriter := &testWriter{ Mutex: &sync.Mutex{}, writes: make([]string, 0), } wg := sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() err := utils.CopyLineByLine(testWriter, r, 4) assert.NoError(t, err) }() // wait for the goroutine to start time.Sleep(time.Millisecond) // write 4 bytes without newline if _, err := w.Write([]byte("12345")); err != nil { t.Fatalf("unexpected error: %v", err) } writes := testWriter.GetWrites() assert.Lenf(t, testWriter.GetWrites(), 1, "expected 1 writes, got: %v", writes) // write more bytes if _, err := w.Write([]byte("67\n89")); err != nil { t.Fatalf("unexpected error: %v", err) } // wait for writer to write time.Sleep(time.Millisecond) writes = testWriter.GetWrites() assert.Lenf(t, testWriter.GetWrites(), 2, "expected 2 writes, got: %v", writes) writes = testWriter.GetWrites() writtenData := strings.Join(writes, "-") assert.Equal(t, "1234-567\n", writtenData, "unexpected writtenData: %s", writtenData) // closing the writer should flush the remaining data w.Close() wg.Wait() } func TestStringReader(t *testing.T) { r := io.NopCloser(strings.NewReader("123\n4567\n890")) testWriter := &testWriter{ Mutex: &sync.Mutex{}, writes: make([]string, 0), } err := utils.CopyLineByLine(testWriter, r, 1024) assert.NoError(t, err) writes := testWriter.GetWrites() assert.Lenf(t, writes, 3, "expected 3 writes, got: %v", writes) } func TestCopyLineByLineNewlineCharacter(t *testing.T) { r, w := io.Pipe() testWriter := &testWriter{ Mutex: &sync.Mutex{}, writes: make([]string, 0), } done := make(chan struct{}) go func() { err := utils.CopyLineByLine(testWriter, r, 4) assert.NoError(t, err) close(done) }() // write one newline character before the maximum size of the buffer if _, err := w.Write([]byte("123\n45678")); err != nil { t.Fatalf("unexpected error: %v", err) } // wait until 2 writes have occurred assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == 2 }, time.Second, 5*time.Millisecond, "expected 2 writes after first write") writes := testWriter.GetWrites() writtenData := strings.Join(writes, "-") assert.Equal(t, "123\n-4567", writtenData) // write one newline character at the beginning before the maximum size of the buffer if _, err := w.Write([]byte("\n123\n45678")); err != nil { t.Fatalf("unexpected error: %v", err) } // wait until 5 writes have occurred (2 from before + 3 new ones) assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == 5 }, time.Second, 5*time.Millisecond, "expected 5 writes total after second write") writes = testWriter.GetWrites() writtenData = strings.Join(writes, "-") assert.Equal(t, "123\n-4567-8\n-123\n-4567", writtenData) // Close the writer first to signal EOF w.Close() // wait for the goroutine to finish select { case <-done: case <-time.After(time.Second): t.Fatal("timeout waiting for goroutine to finish") } // Verify final flush (should have "8" remaining) writes = testWriter.GetWrites() writtenData = strings.Join(writes, "-") assert.Equal(t, "123\n-4567-8\n-123\n-4567-8", writtenData) } // TestCopyLineByLineLongLine is for the long line testing to trigger the writeChunks function. func TestCopyLineByLineLongLine(t *testing.T) { r, w := io.Pipe() testWriter := &testWriter{ Mutex: &sync.Mutex{}, writes: make([]string, 0), } done := make(chan struct{}) // max size = 10 maxSize := 10 go func() { err := utils.CopyLineByLine(testWriter, r, maxSize) assert.NoError(t, err) close(done) }() // wait for the goroutine to start time.Sleep(time.Millisecond) // will trigger the writeChunks function if _, err := w.Write([]byte("this is a very long line\n")); err != nil { t.Fatalf("unexpected error: %v", err) } // wait for the writer to write time.Sleep(time.Millisecond) // verify the number of writes is equal to 3 assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == 3 }, time.Second, 5*time.Millisecond, "expected 3 writes after first write") // verify all data was written correctly writtenData := "" assert.Eventually(t, func() bool { writtenData = strings.Join(testWriter.GetWrites(), "-") return writtenData == "this is a -very long -line\n" }, time.Second, 5*time.Millisecond, "unexpected writtenData: %s", writtenData) // closing the writer should flush the remaining data w.Close() select { case <-done: case <-time.After(time.Second): t.Fatal("timeout waiting for goroutine to finish") } } // TestCopyLineByLineWriteChunks is for the writeChunks function testing. func TestCopyLineByLineWriteChunks(t *testing.T) { r, w := io.Pipe() testWriter := &testWriter{ Mutex: &sync.Mutex{}, writes: make([]string, 0), } done := make(chan struct{}) // max size = 8 maxSize := 8 go func() { err := utils.CopyLineByLine(testWriter, r, maxSize) assert.NoError(t, err) close(done) }() // first line: 20 chars + newline = 21 bytes (will be chunked: 8 + 8 + 5) // second line: 5 chars + newline = 6 bytes (normal write, no chunking) // third line: 16 chars + newline = 17 bytes (will be chunked: 8 + 9) input := "12345678901234567890\n" + "short\n" + "abcdefghijklmnop\n" if _, err := w.Write([]byte(input)); err != nil { t.Fatalf("unexpected error: %v", err) } // verify the number of writes is equal to 7 assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == 7 }, time.Second, 5*time.Millisecond, "expected 7 writes after first write") // verify all data was written correctly writtenData := "" assert.Eventually(t, func() bool { writtenData = strings.Join(testWriter.GetWrites(), "") return writtenData == input }, time.Second, 5*time.Millisecond, "unexpected writtenData: %s", writtenData) // verify the number of writes expectedWrites := 7 assert.Eventually(t, func() bool { return len(testWriter.GetWrites()) == expectedWrites }, time.Second, 5*time.Millisecond, "expected %d writes, got %d: %v", expectedWrites, len(testWriter.GetWrites()), testWriter.GetWrites()) writes := testWriter.GetWrites() // verify first line chunks assert.Equal(t, "12345678", writes[0], "first chunk of first line") assert.Equal(t, "90123456", writes[1], "second chunk of first line") assert.Equal(t, "7890\n", writes[2], "third chunk of first line") // verify second line (not chunked) assert.Equal(t, "short\n", writes[3], "second line should not be chunked") // verify third line chunks assert.Equal(t, "abcdefgh", writes[4], "first chunk of third line") assert.Equal(t, "ijklmnop", writes[5], "second chunk of third line") assert.Equal(t, "\n", writes[6], "third chunk of third line (just newline)") // closing the writer should flush the remaining data w.Close() select { case <-done: case <-time.After(time.Second): t.Fatal("timeout waiting for goroutine to finish") } } ================================================ FILE: release-config.ts ================================================ export default { commentOnReleasedPullRequests: false, skipLabels: ['skip-release', 'skip-changelog', 'regression', 'backport-done'], }; ================================================ FILE: rpc/log_entry.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2011 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "fmt" ) // Identifies the type of line in the logs. const ( LogEntryStdout int = iota LogEntryStderr LogEntryExitCode LogEntryMetadata LogEntryProgress ) // LogEntry is a line of console output. type LogEntry struct { StepUUID string `json:"step_uuid,omitempty"` Time int64 `json:"time,omitempty"` Type int `json:"type,omitempty"` Line int `json:"line,omitempty"` Data []byte `json:"data,omitempty"` } func (l *LogEntry) String() string { switch l.Type { case LogEntryExitCode: return fmt.Sprintf("[%s] exit code %s", l.StepUUID, l.Data) default: return fmt.Sprintf("[%s:L%v:%vs] %s", l.StepUUID, l.Line, l.Time, l.Data) } } ================================================ FILE: rpc/log_entry_test.go ================================================ // Copyright 2019 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "testing" "github.com/stretchr/testify/assert" ) func TestLogEntry(t *testing.T) { line := LogEntry{ StepUUID: "e9ea76a5-44a1-4059-9c4a-6956c478b26d", Time: 60, Line: 1, Data: []byte("starting redis server"), } assert.Equal(t, "[e9ea76a5-44a1-4059-9c4a-6956c478b26d:L1:60s] starting redis server", line.String()) } ================================================ FILE: rpc/mocks/mock_Peer.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/rpc" ) // NewMockPeer creates a new instance of MockPeer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockPeer(t interface { mock.TestingT Cleanup(func()) }) *MockPeer { mock := &MockPeer{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockPeer is an autogenerated mock type for the Peer type type MockPeer struct { mock.Mock } type MockPeer_Expecter struct { mock *mock.Mock } func (_m *MockPeer) EXPECT() *MockPeer_Expecter { return &MockPeer_Expecter{mock: &_m.Mock} } // Done provides a mock function for the type MockPeer func (_mock *MockPeer) Done(c context.Context, workflowID string, state rpc.WorkflowState) error { ret := _mock.Called(c, workflowID, state) if len(ret) == 0 { panic("no return value specified for Done") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string, rpc.WorkflowState) error); ok { r0 = returnFunc(c, workflowID, state) } else { r0 = ret.Error(0) } return r0 } // MockPeer_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done' type MockPeer_Done_Call struct { *mock.Call } // Done is a helper method to define mock.On call // - c context.Context // - workflowID string // - state rpc.WorkflowState func (_e *MockPeer_Expecter) Done(c interface{}, workflowID interface{}, state interface{}) *MockPeer_Done_Call { return &MockPeer_Done_Call{Call: _e.mock.On("Done", c, workflowID, state)} } func (_c *MockPeer_Done_Call) Run(run func(c context.Context, workflowID string, state rpc.WorkflowState)) *MockPeer_Done_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 rpc.WorkflowState if args[2] != nil { arg2 = args[2].(rpc.WorkflowState) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockPeer_Done_Call) Return(err error) *MockPeer_Done_Call { _c.Call.Return(err) return _c } func (_c *MockPeer_Done_Call) RunAndReturn(run func(c context.Context, workflowID string, state rpc.WorkflowState) error) *MockPeer_Done_Call { _c.Call.Return(run) return _c } // EnqueueLog provides a mock function for the type MockPeer func (_mock *MockPeer) EnqueueLog(logEntry *rpc.LogEntry) { _mock.Called(logEntry) return } // MockPeer_EnqueueLog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnqueueLog' type MockPeer_EnqueueLog_Call struct { *mock.Call } // EnqueueLog is a helper method to define mock.On call // - logEntry *rpc.LogEntry func (_e *MockPeer_Expecter) EnqueueLog(logEntry interface{}) *MockPeer_EnqueueLog_Call { return &MockPeer_EnqueueLog_Call{Call: _e.mock.On("EnqueueLog", logEntry)} } func (_c *MockPeer_EnqueueLog_Call) Run(run func(logEntry *rpc.LogEntry)) *MockPeer_EnqueueLog_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *rpc.LogEntry if args[0] != nil { arg0 = args[0].(*rpc.LogEntry) } run( arg0, ) }) return _c } func (_c *MockPeer_EnqueueLog_Call) Return() *MockPeer_EnqueueLog_Call { _c.Call.Return() return _c } func (_c *MockPeer_EnqueueLog_Call) RunAndReturn(run func(logEntry *rpc.LogEntry)) *MockPeer_EnqueueLog_Call { _c.Run(run) return _c } // Extend provides a mock function for the type MockPeer func (_mock *MockPeer) Extend(c context.Context, workflowID string) error { ret := _mock.Called(c, workflowID) if len(ret) == 0 { panic("no return value specified for Extend") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = returnFunc(c, workflowID) } else { r0 = ret.Error(0) } return r0 } // MockPeer_Extend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extend' type MockPeer_Extend_Call struct { *mock.Call } // Extend is a helper method to define mock.On call // - c context.Context // - workflowID string func (_e *MockPeer_Expecter) Extend(c interface{}, workflowID interface{}) *MockPeer_Extend_Call { return &MockPeer_Extend_Call{Call: _e.mock.On("Extend", c, workflowID)} } func (_c *MockPeer_Extend_Call) Run(run func(c context.Context, workflowID string)) *MockPeer_Extend_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockPeer_Extend_Call) Return(err error) *MockPeer_Extend_Call { _c.Call.Return(err) return _c } func (_c *MockPeer_Extend_Call) RunAndReturn(run func(c context.Context, workflowID string) error) *MockPeer_Extend_Call { _c.Call.Return(run) return _c } // Init provides a mock function for the type MockPeer func (_mock *MockPeer) Init(c context.Context, workflowID string, state rpc.WorkflowState) error { ret := _mock.Called(c, workflowID, state) if len(ret) == 0 { panic("no return value specified for Init") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string, rpc.WorkflowState) error); ok { r0 = returnFunc(c, workflowID, state) } else { r0 = ret.Error(0) } return r0 } // MockPeer_Init_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Init' type MockPeer_Init_Call struct { *mock.Call } // Init is a helper method to define mock.On call // - c context.Context // - workflowID string // - state rpc.WorkflowState func (_e *MockPeer_Expecter) Init(c interface{}, workflowID interface{}, state interface{}) *MockPeer_Init_Call { return &MockPeer_Init_Call{Call: _e.mock.On("Init", c, workflowID, state)} } func (_c *MockPeer_Init_Call) Run(run func(c context.Context, workflowID string, state rpc.WorkflowState)) *MockPeer_Init_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 rpc.WorkflowState if args[2] != nil { arg2 = args[2].(rpc.WorkflowState) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockPeer_Init_Call) Return(err error) *MockPeer_Init_Call { _c.Call.Return(err) return _c } func (_c *MockPeer_Init_Call) RunAndReturn(run func(c context.Context, workflowID string, state rpc.WorkflowState) error) *MockPeer_Init_Call { _c.Call.Return(run) return _c } // IsConnected provides a mock function for the type MockPeer func (_mock *MockPeer) IsConnected() bool { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for IsConnected") } var r0 bool if returnFunc, ok := ret.Get(0).(func() bool); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(bool) } return r0 } // MockPeer_IsConnected_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsConnected' type MockPeer_IsConnected_Call struct { *mock.Call } // IsConnected is a helper method to define mock.On call func (_e *MockPeer_Expecter) IsConnected() *MockPeer_IsConnected_Call { return &MockPeer_IsConnected_Call{Call: _e.mock.On("IsConnected")} } func (_c *MockPeer_IsConnected_Call) Run(run func()) *MockPeer_IsConnected_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockPeer_IsConnected_Call) Return(b bool) *MockPeer_IsConnected_Call { _c.Call.Return(b) return _c } func (_c *MockPeer_IsConnected_Call) RunAndReturn(run func() bool) *MockPeer_IsConnected_Call { _c.Call.Return(run) return _c } // Next provides a mock function for the type MockPeer func (_mock *MockPeer) Next(c context.Context, f rpc.Filter) (*rpc.Workflow, error) { ret := _mock.Called(c, f) if len(ret) == 0 { panic("no return value specified for Next") } var r0 *rpc.Workflow var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, rpc.Filter) (*rpc.Workflow, error)); ok { return returnFunc(c, f) } if returnFunc, ok := ret.Get(0).(func(context.Context, rpc.Filter) *rpc.Workflow); ok { r0 = returnFunc(c, f) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*rpc.Workflow) } } if returnFunc, ok := ret.Get(1).(func(context.Context, rpc.Filter) error); ok { r1 = returnFunc(c, f) } else { r1 = ret.Error(1) } return r0, r1 } // MockPeer_Next_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Next' type MockPeer_Next_Call struct { *mock.Call } // Next is a helper method to define mock.On call // - c context.Context // - f rpc.Filter func (_e *MockPeer_Expecter) Next(c interface{}, f interface{}) *MockPeer_Next_Call { return &MockPeer_Next_Call{Call: _e.mock.On("Next", c, f)} } func (_c *MockPeer_Next_Call) Run(run func(c context.Context, f rpc.Filter)) *MockPeer_Next_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 rpc.Filter if args[1] != nil { arg1 = args[1].(rpc.Filter) } run( arg0, arg1, ) }) return _c } func (_c *MockPeer_Next_Call) Return(workflow *rpc.Workflow, err error) *MockPeer_Next_Call { _c.Call.Return(workflow, err) return _c } func (_c *MockPeer_Next_Call) RunAndReturn(run func(c context.Context, f rpc.Filter) (*rpc.Workflow, error)) *MockPeer_Next_Call { _c.Call.Return(run) return _c } // RegisterAgent provides a mock function for the type MockPeer func (_mock *MockPeer) RegisterAgent(ctx context.Context, info rpc.AgentInfo) (int64, error) { ret := _mock.Called(ctx, info) if len(ret) == 0 { panic("no return value specified for RegisterAgent") } var r0 int64 var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, rpc.AgentInfo) (int64, error)); ok { return returnFunc(ctx, info) } if returnFunc, ok := ret.Get(0).(func(context.Context, rpc.AgentInfo) int64); ok { r0 = returnFunc(ctx, info) } else { r0 = ret.Get(0).(int64) } if returnFunc, ok := ret.Get(1).(func(context.Context, rpc.AgentInfo) error); ok { r1 = returnFunc(ctx, info) } else { r1 = ret.Error(1) } return r0, r1 } // MockPeer_RegisterAgent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterAgent' type MockPeer_RegisterAgent_Call struct { *mock.Call } // RegisterAgent is a helper method to define mock.On call // - ctx context.Context // - info rpc.AgentInfo func (_e *MockPeer_Expecter) RegisterAgent(ctx interface{}, info interface{}) *MockPeer_RegisterAgent_Call { return &MockPeer_RegisterAgent_Call{Call: _e.mock.On("RegisterAgent", ctx, info)} } func (_c *MockPeer_RegisterAgent_Call) Run(run func(ctx context.Context, info rpc.AgentInfo)) *MockPeer_RegisterAgent_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 rpc.AgentInfo if args[1] != nil { arg1 = args[1].(rpc.AgentInfo) } run( arg0, arg1, ) }) return _c } func (_c *MockPeer_RegisterAgent_Call) Return(n int64, err error) *MockPeer_RegisterAgent_Call { _c.Call.Return(n, err) return _c } func (_c *MockPeer_RegisterAgent_Call) RunAndReturn(run func(ctx context.Context, info rpc.AgentInfo) (int64, error)) *MockPeer_RegisterAgent_Call { _c.Call.Return(run) return _c } // ReportHealth provides a mock function for the type MockPeer func (_mock *MockPeer) ReportHealth(c context.Context) error { ret := _mock.Called(c) if len(ret) == 0 { panic("no return value specified for ReportHealth") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { r0 = returnFunc(c) } else { r0 = ret.Error(0) } return r0 } // MockPeer_ReportHealth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReportHealth' type MockPeer_ReportHealth_Call struct { *mock.Call } // ReportHealth is a helper method to define mock.On call // - c context.Context func (_e *MockPeer_Expecter) ReportHealth(c interface{}) *MockPeer_ReportHealth_Call { return &MockPeer_ReportHealth_Call{Call: _e.mock.On("ReportHealth", c)} } func (_c *MockPeer_ReportHealth_Call) Run(run func(c context.Context)) *MockPeer_ReportHealth_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } run( arg0, ) }) return _c } func (_c *MockPeer_ReportHealth_Call) Return(err error) *MockPeer_ReportHealth_Call { _c.Call.Return(err) return _c } func (_c *MockPeer_ReportHealth_Call) RunAndReturn(run func(c context.Context) error) *MockPeer_ReportHealth_Call { _c.Call.Return(run) return _c } // UnregisterAgent provides a mock function for the type MockPeer func (_mock *MockPeer) UnregisterAgent(ctx context.Context) error { ret := _mock.Called(ctx) if len(ret) == 0 { panic("no return value specified for UnregisterAgent") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { r0 = returnFunc(ctx) } else { r0 = ret.Error(0) } return r0 } // MockPeer_UnregisterAgent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnregisterAgent' type MockPeer_UnregisterAgent_Call struct { *mock.Call } // UnregisterAgent is a helper method to define mock.On call // - ctx context.Context func (_e *MockPeer_Expecter) UnregisterAgent(ctx interface{}) *MockPeer_UnregisterAgent_Call { return &MockPeer_UnregisterAgent_Call{Call: _e.mock.On("UnregisterAgent", ctx)} } func (_c *MockPeer_UnregisterAgent_Call) Run(run func(ctx context.Context)) *MockPeer_UnregisterAgent_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } run( arg0, ) }) return _c } func (_c *MockPeer_UnregisterAgent_Call) Return(err error) *MockPeer_UnregisterAgent_Call { _c.Call.Return(err) return _c } func (_c *MockPeer_UnregisterAgent_Call) RunAndReturn(run func(ctx context.Context) error) *MockPeer_UnregisterAgent_Call { _c.Call.Return(run) return _c } // Update provides a mock function for the type MockPeer func (_mock *MockPeer) Update(c context.Context, workflowID string, state rpc.StepState) error { ret := _mock.Called(c, workflowID, state) if len(ret) == 0 { panic("no return value specified for Update") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string, rpc.StepState) error); ok { r0 = returnFunc(c, workflowID, state) } else { r0 = ret.Error(0) } return r0 } // MockPeer_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' type MockPeer_Update_Call struct { *mock.Call } // Update is a helper method to define mock.On call // - c context.Context // - workflowID string // - state rpc.StepState func (_e *MockPeer_Expecter) Update(c interface{}, workflowID interface{}, state interface{}) *MockPeer_Update_Call { return &MockPeer_Update_Call{Call: _e.mock.On("Update", c, workflowID, state)} } func (_c *MockPeer_Update_Call) Run(run func(c context.Context, workflowID string, state rpc.StepState)) *MockPeer_Update_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 rpc.StepState if args[2] != nil { arg2 = args[2].(rpc.StepState) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockPeer_Update_Call) Return(err error) *MockPeer_Update_Call { _c.Call.Return(err) return _c } func (_c *MockPeer_Update_Call) RunAndReturn(run func(c context.Context, workflowID string, state rpc.StepState) error) *MockPeer_Update_Call { _c.Call.Return(run) return _c } // Version provides a mock function for the type MockPeer func (_mock *MockPeer) Version(c context.Context) (*rpc.Version, error) { ret := _mock.Called(c) if len(ret) == 0 { panic("no return value specified for Version") } var r0 *rpc.Version var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context) (*rpc.Version, error)); ok { return returnFunc(c) } if returnFunc, ok := ret.Get(0).(func(context.Context) *rpc.Version); ok { r0 = returnFunc(c) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*rpc.Version) } } if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { r1 = returnFunc(c) } else { r1 = ret.Error(1) } return r0, r1 } // MockPeer_Version_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Version' type MockPeer_Version_Call struct { *mock.Call } // Version is a helper method to define mock.On call // - c context.Context func (_e *MockPeer_Expecter) Version(c interface{}) *MockPeer_Version_Call { return &MockPeer_Version_Call{Call: _e.mock.On("Version", c)} } func (_c *MockPeer_Version_Call) Run(run func(c context.Context)) *MockPeer_Version_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } run( arg0, ) }) return _c } func (_c *MockPeer_Version_Call) Return(version *rpc.Version, err error) *MockPeer_Version_Call { _c.Call.Return(version, err) return _c } func (_c *MockPeer_Version_Call) RunAndReturn(run func(c context.Context) (*rpc.Version, error)) *MockPeer_Version_Call { _c.Call.Return(run) return _c } // Wait provides a mock function for the type MockPeer func (_mock *MockPeer) Wait(c context.Context, workflowID string) (bool, error) { ret := _mock.Called(c, workflowID) if len(ret) == 0 { panic("no return value specified for Wait") } var r0 bool var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { return returnFunc(c, workflowID) } if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { r0 = returnFunc(c, workflowID) } else { r0 = ret.Get(0).(bool) } if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { r1 = returnFunc(c, workflowID) } else { r1 = ret.Error(1) } return r0, r1 } // MockPeer_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait' type MockPeer_Wait_Call struct { *mock.Call } // Wait is a helper method to define mock.On call // - c context.Context // - workflowID string func (_e *MockPeer_Expecter) Wait(c interface{}, workflowID interface{}) *MockPeer_Wait_Call { return &MockPeer_Wait_Call{Call: _e.mock.On("Wait", c, workflowID)} } func (_c *MockPeer_Wait_Call) Run(run func(c context.Context, workflowID string)) *MockPeer_Wait_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockPeer_Wait_Call) Return(canceled bool, err error) *MockPeer_Wait_Call { _c.Call.Return(canceled, err) return _c } func (_c *MockPeer_Wait_Call) RunAndReturn(run func(c context.Context, workflowID string) (bool, error)) *MockPeer_Wait_Call { _c.Call.Return(run) return _c } ================================================ FILE: rpc/peer.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2011 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import "context" // Peer defines the bidirectional communication interface between Woodpecker agents and servers. // // # Architecture and Implementations // // The Peer interface is implemented differently on each side of the communication: // // - Agent side: Implemented by agent/rpc/client_grpc.go's client struct, which wraps // a gRPC client connection to make RPC calls to the server. // // - Server side: Implemented by server/rpc/rpc.go's RPC struct, which contains the // business logic and is wrapped by server/rpc/server.go's WoodpeckerServer struct // to handle incoming gRPC requests. // // # Thread Safety and Concurrency // // - Implementations must be safe for concurrent calls across different workflows // - The same Peer instance may be called concurrently from multiple goroutines // - Each workflow is identified by a unique workflowID string // - Implementations must properly isolate workflow state using workflowID // // # Error Handling Conventions // // - Methods return errors for communication failures, validation errors, or server-side issues // - Errors should not be used for business logic // - Network/transport errors should be retried by the caller when appropriate // - Nil error indicates successful operation // - Context cancellation should return nil or context.Canceled, not a custom error // - Business logic errors (e.g., workflow not found) return specific error types // // # Intended Execution Flow // // 1. Agent Lifecycle: // - Version() checks compatibility with server // - RegisterAgent() announces agent availability // - ReportHealth() periodically confirms agent is alive // - UnregisterAgent() gracefully disconnects agent // // 2. Workflow Execution (may happen concurrently for multiple workflows): // - Next() blocks until server assigns a workflow // - Init() signals workflow execution has started // - Wait() (in background goroutine) monitors for cancellation signals // - Update() reports step state changes as workflow progresses // - EnqueueLog() streams log output from steps // - Extend() extends workflow timeout if needed so queue does not reschedule it as retry // - Done() signals workflow has completed // // 3. Cancellation Flow: // - Server can cancel workflow by releasing Wait() with canceled=true // - Agent detects cancellation from Wait() return value // - Agent stops workflow execution and calls Done() with canceled state type Peer interface { // Version returns the server and gRPC protocol version information. // // This is typically called once during agent initialization to verify // compatibility between agent and server versions. // // Returns: // - Version with server version string and gRPC protocol version number // - Error if communication fails or server is unreachable Version(c context.Context) (*Version, error) // Next blocks until the server provides the next workflow to execute from the queue. // // This is the primary work-polling mechanism. Agents call this repeatedly in a loop, // and it blocks until either: // 1. A workflow matching the filter becomes available // 2. The context is canceled (agent shutdown, network timeout, etc.) // // The filter allows agents to specify capabilities via labels (e.g., platform, // backend type) so the server only assigns compatible workflows. // // Context Handling: // - This is a long-polling operation that may block for extended periods // - Implementations MUST check context regularly (not just at entry) // - When context is canceled, must return nil workflow and nil error // - Server may send keep-alive signals or periodically return nil to allow reconnection // // Returns: // - Workflow object with ID, Config, and Workflow.Timeout if work is available // - nil, nil if context is canceled or no work available (retry expected) // - nil, error if a non-retryable error occurs Next(c context.Context, f Filter) (*Workflow, error) // Wait blocks until the workflow with the given ID completes or is canceled by the server. // // This is used by agents to monitor for server-side cancellation signals. Typically // called in a background goroutine immediately after Init(), running concurrently // with workflow execution. // // The method serves two purposes: // 1. Signals when server wants to cancel workflow (canceled=true) // 2. Unblocks when workflow completes normally on agent (canceled=false) // // Context Handling: // - This is a long-running blocking operation for the workflow duration // - Context cancellation indicates shutdown, not workflow cancellation // - When context is canceled, should return (false, nil) or (false, ctx.Err()) // - Must not confuse context cancellation with workflow cancellation signal // // Cancellation Flow: // - Server releases Wait() with canceled=true → agent should stop workflow // - Agent completes workflow normally → Done() is called → server releases Wait() with canceled=false // - Agent context canceled → Wait() returns immediately, workflow may continue on agent // // Returns: // - canceled=true, err=nil: Server initiated cancellation, agent should stop workflow // - canceled=false, err=nil: Workflow completed normally (Wait unblocked by Done call) // - canceled=false, err!=nil: Communication error, agent should retry or handle error Wait(c context.Context, workflowID string) (canceled bool, err error) // Init signals to the server that the workflow has been initialized and execution has started. // // This is called once per workflow immediately after the agent accepts it from Next() // and before starting step execution. It allows the server to track workflow start time // and update workflow status to "running". // // The WorkflowState should have: // - Started: Unix timestamp when execution began // - Finished: 0 (not finished yet) // - Error: empty string (no error yet) // - Canceled: false (not canceled yet) // // Returns: // - nil on success // - error if communication fails or server rejects the state Init(c context.Context, workflowID string, state WorkflowState) error // Done signals to the server that the workflow has completed execution. // // This is called once per workflow after all steps have finished (or workflow was canceled). // It provides the final workflow state including completion time, any errors, and // cancellation status. // // The WorkflowState should have: // - Started: Unix timestamp when execution began (same as Init) // - Finished: Unix timestamp when execution completed // - Error: Error message if workflow failed, empty if successful // - Canceled: true if workflow was canceled, false otherwise // // After Done() is called: // - Server updates final workflow status in database // - Server releases any Wait() calls for this workflow // - Server removes workflow from active queue // - Server notifies forge of workflow completion // // Context Handling: // - MUST attempt to complete even if workflow context is canceled // - Often called with a shutdown/cleanup context rather than workflow context // - Critical for proper cleanup - should retry on transient failures // // Returns: // - nil on success // - error if communication fails or server rejects the state Done(c context.Context, workflowID string, state WorkflowState) error // Extend extends the timeout for the workflow with the given ID in the task queue. // // Agents must call Extend() regularly (e.g., every constant.TaskTimeout / 3) to signal // that the workflow is still actively executing and prevent premature timeout. // // If agents don't call Extend periodically, the workflow will be rescheduled to a new // agent after the timeout period expires (specified in constant.TaskTimeout). // // This acts as a heartbeat mechanism to detect stuck workflow executions. If an agent // dies or becomes unresponsive, the server will eventually timeout the workflow and // reassign it. // // IMPORTANT: Don't confuse this with Workflow.Timeout returned by Next() - they serve // different purposes! // // Returns: // - nil on success (timeout was extended) // - error if communication fails or workflow is not found Extend(c context.Context, workflowID string) error // Update reports step state changes to the server as the workflow progresses. // // This is called multiple times per step: // 1. When step starts (Exited=false, Finished=0) // 2. When step completes (Exited=true, Finished and ExitCode set) // 3. Potentially on progress updates if step has long-running operations // // The server uses these updates to: // - Track step execution progress // - Update UI with real-time status // - Store step results in database // - Calculate workflow completion // // Context Handling: // - Failures should be logged but not block workflow execution // // Returns: // - nil on success // - error if communication fails or server rejects the state Update(c context.Context, workflowID string, state StepState) error // EnqueueLog queues a log entry for delayed batch sending to the server. // // Log entries are produced continuously during step execution and need to be // transmitted efficiently. This method adds logs to an internal queue that // batches and sends them periodically to reduce network overhead. // // The implementation should: // - Queue the log entry in a memory buffer // - Batch multiple entries together // - Send batches periodically (e.g., every second) or when buffer fills // - Handle backpressure if server is slow or network is congested // // Unlike other methods, EnqueueLog: // - Does NOT take a context parameter (fire-and-forget) // - Does NOT return an error (never blocks the caller) // - Does NOT guarantee immediate transmission // // Thread Safety: // - MUST be safe to call concurrently from multiple goroutines // - May be called concurrently from different steps/workflows // - Internal queue must be properly synchronized EnqueueLog(logEntry *LogEntry) // RegisterAgent announces this agent to the server and returns an agent ID. // // This is called once during agent startup to: // - Create an agent record in the server database // - Obtain a unique agent ID for subsequent requests // - Declare agent capabilities (platform, backend, capacity, labels) // - Enable server-side agent tracking and monitoring // // The AgentInfo should specify: // - Version: Agent version string (e.g., "v2.0.0") // - Platform: OS/architecture (e.g., "linux/amd64") // - Backend: Execution backend (e.g., "docker", "kubernetes") // - Capacity: Maximum concurrent workflows (e.g., 2) // - CustomLabels: Additional key-value labels for filtering // // Context Handling: // - Context cancellation indicates agent is aborting startup // - Should not retry indefinitely - fail fast on persistent errors // // Returns: // - agentID: Unique identifier for this agent (use in subsequent calls) // - error: If registration fails RegisterAgent(ctx context.Context, info AgentInfo) (int64, error) // UnregisterAgent removes this agent from the server's registry. // // This is called during graceful agent shutdown to: // - Mark agent as offline in server database // - Allow server to stop assigning workflows to this agent // - Clean up any agent-specific server resources // - Provide clean shutdown signal to monitoring systems // // After UnregisterAgent: // - Agent should stop calling Next() for new work // - Agent should complete any in-progress workflows // - Agent may call Done() to finish existing workflows // - Agent should close network connections // // Context Handling: // - MUST attempt to complete even during forced shutdown // - Often called with a shutdown context (limited time) // - Failure is logged but should not prevent agent exit // // Returns: // - nil on success // - error if communication fails UnregisterAgent(ctx context.Context) error // ReportHealth sends a periodic health status update to the server. // // This is called regularly (e.g., every 30 seconds) during agent operation to: // - Prove agent is still alive and responsive // - Allow server to detect dead or stuck agents // - Update agent's "last seen" timestamp in database // - Provide application-level keepalive beyond network keep-alive signals // // Health reporting helps the server: // - Mark unresponsive agents as offline // - Redistribute work from dead agents // - Display accurate agent status in UI // - Trigger alerts for infrastructure issues // // Returns: // - nil on success // - error if communication fails ReportHealth(c context.Context) error // IsConnected returns true if the gRPC connection to the server is in Ready state. // // This can be used to check if the server is reachable before attempting // operations that require a connection (like UnregisterAgent). // // Returns: // - true if connection is Ready // - false otherwise (Idle, Connecting, Shutdown, or TransientFailure) IsConnected() bool } ================================================ FILE: rpc/proto/generate.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proto //go:generate protoc --go_out=paths=source_relative:. woodpecker.proto //go:generate protoc --go-grpc_out=paths=source_relative:. woodpecker.proto ================================================ FILE: rpc/proto/version.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package proto // Version is the version of the woodpecker.proto file, // IMPORTANT: increased by 1 each time it get changed. const Version int32 = 16 ================================================ FILE: rpc/proto/woodpecker.pb.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2011 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc v6.33.1 // source: woodpecker.proto package proto import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type StepState struct { state protoimpl.MessageState `protogen:"open.v1"` StepUuid string `protobuf:"bytes,1,opt,name=step_uuid,json=stepUuid,proto3" json:"step_uuid,omitempty"` Started int64 `protobuf:"varint,2,opt,name=started,proto3" json:"started,omitempty"` Finished int64 `protobuf:"varint,3,opt,name=finished,proto3" json:"finished,omitempty"` Exited bool `protobuf:"varint,4,opt,name=exited,proto3" json:"exited,omitempty"` ExitCode int32 `protobuf:"varint,5,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"` Canceled bool `protobuf:"varint,7,opt,name=canceled,proto3" json:"canceled,omitempty"` Skipped bool `protobuf:"varint,8,opt,name=skipped,proto3" json:"skipped,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StepState) Reset() { *x = StepState{} mi := &file_woodpecker_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *StepState) String() string { return protoimpl.X.MessageStringOf(x) } func (*StepState) ProtoMessage() {} func (x *StepState) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use StepState.ProtoReflect.Descriptor instead. func (*StepState) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{0} } func (x *StepState) GetStepUuid() string { if x != nil { return x.StepUuid } return "" } func (x *StepState) GetStarted() int64 { if x != nil { return x.Started } return 0 } func (x *StepState) GetFinished() int64 { if x != nil { return x.Finished } return 0 } func (x *StepState) GetExited() bool { if x != nil { return x.Exited } return false } func (x *StepState) GetExitCode() int32 { if x != nil { return x.ExitCode } return 0 } func (x *StepState) GetError() string { if x != nil { return x.Error } return "" } func (x *StepState) GetCanceled() bool { if x != nil { return x.Canceled } return false } func (x *StepState) GetSkipped() bool { if x != nil { return x.Skipped } return false } type WorkflowState struct { state protoimpl.MessageState `protogen:"open.v1"` Started int64 `protobuf:"varint,1,opt,name=started,proto3" json:"started,omitempty"` Finished int64 `protobuf:"varint,2,opt,name=finished,proto3" json:"finished,omitempty"` Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` Canceled bool `protobuf:"varint,4,opt,name=canceled,proto3" json:"canceled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *WorkflowState) Reset() { *x = WorkflowState{} mi := &file_woodpecker_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *WorkflowState) String() string { return protoimpl.X.MessageStringOf(x) } func (*WorkflowState) ProtoMessage() {} func (x *WorkflowState) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use WorkflowState.ProtoReflect.Descriptor instead. func (*WorkflowState) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{1} } func (x *WorkflowState) GetStarted() int64 { if x != nil { return x.Started } return 0 } func (x *WorkflowState) GetFinished() int64 { if x != nil { return x.Finished } return 0 } func (x *WorkflowState) GetError() string { if x != nil { return x.Error } return "" } func (x *WorkflowState) GetCanceled() bool { if x != nil { return x.Canceled } return false } type LogEntry struct { state protoimpl.MessageState `protogen:"open.v1"` StepUuid string `protobuf:"bytes,1,opt,name=step_uuid,json=stepUuid,proto3" json:"step_uuid,omitempty"` Time int64 `protobuf:"varint,2,opt,name=time,proto3" json:"time,omitempty"` Line int32 `protobuf:"varint,3,opt,name=line,proto3" json:"line,omitempty"` Type int32 `protobuf:"varint,4,opt,name=type,proto3" json:"type,omitempty"` // 0 = stdout, 1 = stderr, 2 = exit-code, 3 = metadata, 4 = progress Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LogEntry) Reset() { *x = LogEntry{} mi := &file_woodpecker_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LogEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*LogEntry) ProtoMessage() {} func (x *LogEntry) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. func (*LogEntry) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{2} } func (x *LogEntry) GetStepUuid() string { if x != nil { return x.StepUuid } return "" } func (x *LogEntry) GetTime() int64 { if x != nil { return x.Time } return 0 } func (x *LogEntry) GetLine() int32 { if x != nil { return x.Line } return 0 } func (x *LogEntry) GetType() int32 { if x != nil { return x.Type } return 0 } func (x *LogEntry) GetData() []byte { if x != nil { return x.Data } return nil } type Filter struct { state protoimpl.MessageState `protogen:"open.v1"` Labels map[string]string `protobuf:"bytes,1,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Filter) Reset() { *x = Filter{} mi := &file_woodpecker_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Filter) String() string { return protoimpl.X.MessageStringOf(x) } func (*Filter) ProtoMessage() {} func (x *Filter) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Filter.ProtoReflect.Descriptor instead. func (*Filter) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{3} } func (x *Filter) GetLabels() map[string]string { if x != nil { return x.Labels } return nil } type Workflow struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Timeout int64 `protobuf:"varint,2,opt,name=timeout,proto3" json:"timeout,omitempty"` Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Workflow) Reset() { *x = Workflow{} mi := &file_woodpecker_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Workflow) String() string { return protoimpl.X.MessageStringOf(x) } func (*Workflow) ProtoMessage() {} func (x *Workflow) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Workflow.ProtoReflect.Descriptor instead. func (*Workflow) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{4} } func (x *Workflow) GetId() string { if x != nil { return x.Id } return "" } func (x *Workflow) GetTimeout() int64 { if x != nil { return x.Timeout } return 0 } func (x *Workflow) GetPayload() []byte { if x != nil { return x.Payload } return nil } type NextRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Filter *Filter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *NextRequest) Reset() { *x = NextRequest{} mi := &file_woodpecker_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *NextRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*NextRequest) ProtoMessage() {} func (x *NextRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use NextRequest.ProtoReflect.Descriptor instead. func (*NextRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{5} } func (x *NextRequest) GetFilter() *Filter { if x != nil { return x.Filter } return nil } type InitRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` State *WorkflowState `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *InitRequest) Reset() { *x = InitRequest{} mi := &file_woodpecker_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *InitRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*InitRequest) ProtoMessage() {} func (x *InitRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use InitRequest.ProtoReflect.Descriptor instead. func (*InitRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{6} } func (x *InitRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *InitRequest) GetState() *WorkflowState { if x != nil { return x.State } return nil } type WaitRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *WaitRequest) Reset() { *x = WaitRequest{} mi := &file_woodpecker_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *WaitRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*WaitRequest) ProtoMessage() {} func (x *WaitRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use WaitRequest.ProtoReflect.Descriptor instead. func (*WaitRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{7} } func (x *WaitRequest) GetId() string { if x != nil { return x.Id } return "" } type DoneRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` State *WorkflowState `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DoneRequest) Reset() { *x = DoneRequest{} mi := &file_woodpecker_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *DoneRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*DoneRequest) ProtoMessage() {} func (x *DoneRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use DoneRequest.ProtoReflect.Descriptor instead. func (*DoneRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{8} } func (x *DoneRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *DoneRequest) GetState() *WorkflowState { if x != nil { return x.State } return nil } type ExtendRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ExtendRequest) Reset() { *x = ExtendRequest{} mi := &file_woodpecker_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ExtendRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ExtendRequest) ProtoMessage() {} func (x *ExtendRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ExtendRequest.ProtoReflect.Descriptor instead. func (*ExtendRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{9} } func (x *ExtendRequest) GetId() string { if x != nil { return x.Id } return "" } type UpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` State *StepState `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateRequest) Reset() { *x = UpdateRequest{} mi := &file_woodpecker_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *UpdateRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*UpdateRequest) ProtoMessage() {} func (x *UpdateRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. func (*UpdateRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{10} } func (x *UpdateRequest) GetId() string { if x != nil { return x.Id } return "" } func (x *UpdateRequest) GetState() *StepState { if x != nil { return x.State } return nil } type LogRequest struct { state protoimpl.MessageState `protogen:"open.v1"` LogEntries []*LogEntry `protobuf:"bytes,1,rep,name=logEntries,proto3" json:"logEntries,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LogRequest) Reset() { *x = LogRequest{} mi := &file_woodpecker_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *LogRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*LogRequest) ProtoMessage() {} func (x *LogRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use LogRequest.ProtoReflect.Descriptor instead. func (*LogRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{11} } func (x *LogRequest) GetLogEntries() []*LogEntry { if x != nil { return x.LogEntries } return nil } type Empty struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Empty) Reset() { *x = Empty{} mi := &file_woodpecker_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Empty) String() string { return protoimpl.X.MessageStringOf(x) } func (*Empty) ProtoMessage() {} func (x *Empty) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Empty.ProtoReflect.Descriptor instead. func (*Empty) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{12} } type ReportHealthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReportHealthRequest) Reset() { *x = ReportHealthRequest{} mi := &file_woodpecker_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ReportHealthRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*ReportHealthRequest) ProtoMessage() {} func (x *ReportHealthRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ReportHealthRequest.ProtoReflect.Descriptor instead. func (*ReportHealthRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{13} } func (x *ReportHealthRequest) GetStatus() string { if x != nil { return x.Status } return "" } type AgentInfo struct { state protoimpl.MessageState `protogen:"open.v1"` Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform,omitempty"` Capacity int32 `protobuf:"varint,2,opt,name=capacity,proto3" json:"capacity,omitempty"` Backend string `protobuf:"bytes,3,opt,name=backend,proto3" json:"backend,omitempty"` Version string `protobuf:"bytes,4,opt,name=version,proto3" json:"version,omitempty"` CustomLabels map[string]string `protobuf:"bytes,5,rep,name=customLabels,proto3" json:"customLabels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AgentInfo) Reset() { *x = AgentInfo{} mi := &file_woodpecker_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AgentInfo) String() string { return protoimpl.X.MessageStringOf(x) } func (*AgentInfo) ProtoMessage() {} func (x *AgentInfo) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AgentInfo.ProtoReflect.Descriptor instead. func (*AgentInfo) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{14} } func (x *AgentInfo) GetPlatform() string { if x != nil { return x.Platform } return "" } func (x *AgentInfo) GetCapacity() int32 { if x != nil { return x.Capacity } return 0 } func (x *AgentInfo) GetBackend() string { if x != nil { return x.Backend } return "" } func (x *AgentInfo) GetVersion() string { if x != nil { return x.Version } return "" } func (x *AgentInfo) GetCustomLabels() map[string]string { if x != nil { return x.CustomLabels } return nil } type RegisterAgentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Info *AgentInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RegisterAgentRequest) Reset() { *x = RegisterAgentRequest{} mi := &file_woodpecker_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RegisterAgentRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*RegisterAgentRequest) ProtoMessage() {} func (x *RegisterAgentRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RegisterAgentRequest.ProtoReflect.Descriptor instead. func (*RegisterAgentRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{15} } func (x *RegisterAgentRequest) GetInfo() *AgentInfo { if x != nil { return x.Info } return nil } type VersionResponse struct { state protoimpl.MessageState `protogen:"open.v1"` GrpcVersion int32 `protobuf:"varint,1,opt,name=grpc_version,json=grpcVersion,proto3" json:"grpc_version,omitempty"` ServerVersion string `protobuf:"bytes,2,opt,name=server_version,json=serverVersion,proto3" json:"server_version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *VersionResponse) Reset() { *x = VersionResponse{} mi := &file_woodpecker_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *VersionResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*VersionResponse) ProtoMessage() {} func (x *VersionResponse) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use VersionResponse.ProtoReflect.Descriptor instead. func (*VersionResponse) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{16} } func (x *VersionResponse) GetGrpcVersion() int32 { if x != nil { return x.GrpcVersion } return 0 } func (x *VersionResponse) GetServerVersion() string { if x != nil { return x.ServerVersion } return "" } type NextResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Workflow *Workflow `protobuf:"bytes,1,opt,name=workflow,proto3" json:"workflow,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *NextResponse) Reset() { *x = NextResponse{} mi := &file_woodpecker_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *NextResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*NextResponse) ProtoMessage() {} func (x *NextResponse) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use NextResponse.ProtoReflect.Descriptor instead. func (*NextResponse) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{17} } func (x *NextResponse) GetWorkflow() *Workflow { if x != nil { return x.Workflow } return nil } type RegisterAgentResponse struct { state protoimpl.MessageState `protogen:"open.v1"` AgentId int64 `protobuf:"varint,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RegisterAgentResponse) Reset() { *x = RegisterAgentResponse{} mi := &file_woodpecker_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *RegisterAgentResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*RegisterAgentResponse) ProtoMessage() {} func (x *RegisterAgentResponse) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use RegisterAgentResponse.ProtoReflect.Descriptor instead. func (*RegisterAgentResponse) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{18} } func (x *RegisterAgentResponse) GetAgentId() int64 { if x != nil { return x.AgentId } return 0 } type WaitResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Canceled bool `protobuf:"varint,1,opt,name=canceled,proto3" json:"canceled,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *WaitResponse) Reset() { *x = WaitResponse{} mi := &file_woodpecker_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *WaitResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*WaitResponse) ProtoMessage() {} func (x *WaitResponse) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use WaitResponse.ProtoReflect.Descriptor instead. func (*WaitResponse) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{19} } func (x *WaitResponse) GetCanceled() bool { if x != nil { return x.Canceled } return false } type AuthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"` AgentId int64 `protobuf:"varint,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AuthRequest) Reset() { *x = AuthRequest{} mi := &file_woodpecker_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AuthRequest) String() string { return protoimpl.X.MessageStringOf(x) } func (*AuthRequest) ProtoMessage() {} func (x *AuthRequest) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AuthRequest.ProtoReflect.Descriptor instead. func (*AuthRequest) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{20} } func (x *AuthRequest) GetAgentToken() string { if x != nil { return x.AgentToken } return "" } func (x *AuthRequest) GetAgentId() int64 { if x != nil { return x.AgentId } return 0 } type AuthResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` AgentId int64 `protobuf:"varint,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` AccessToken string `protobuf:"bytes,3,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *AuthResponse) Reset() { *x = AuthResponse{} mi := &file_woodpecker_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *AuthResponse) String() string { return protoimpl.X.MessageStringOf(x) } func (*AuthResponse) ProtoMessage() {} func (x *AuthResponse) ProtoReflect() protoreflect.Message { mi := &file_woodpecker_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use AuthResponse.ProtoReflect.Descriptor instead. func (*AuthResponse) Descriptor() ([]byte, []int) { return file_woodpecker_proto_rawDescGZIP(), []int{21} } func (x *AuthResponse) GetStatus() string { if x != nil { return x.Status } return "" } func (x *AuthResponse) GetAgentId() int64 { if x != nil { return x.AgentId } return 0 } func (x *AuthResponse) GetAccessToken() string { if x != nil { return x.AccessToken } return "" } var File_woodpecker_proto protoreflect.FileDescriptor const file_woodpecker_proto_rawDesc = "" + "\n" + "\x10woodpecker.proto\x12\x05proto\"\xdf\x01\n" + "\tStepState\x12\x1b\n" + "\tstep_uuid\x18\x01 \x01(\tR\bstepUuid\x12\x18\n" + "\astarted\x18\x02 \x01(\x03R\astarted\x12\x1a\n" + "\bfinished\x18\x03 \x01(\x03R\bfinished\x12\x16\n" + "\x06exited\x18\x04 \x01(\bR\x06exited\x12\x1b\n" + "\texit_code\x18\x05 \x01(\x05R\bexitCode\x12\x14\n" + "\x05error\x18\x06 \x01(\tR\x05error\x12\x1a\n" + "\bcanceled\x18\a \x01(\bR\bcanceled\x12\x18\n" + "\askipped\x18\b \x01(\bR\askipped\"w\n" + "\rWorkflowState\x12\x18\n" + "\astarted\x18\x01 \x01(\x03R\astarted\x12\x1a\n" + "\bfinished\x18\x02 \x01(\x03R\bfinished\x12\x14\n" + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1a\n" + "\bcanceled\x18\x04 \x01(\bR\bcanceled\"w\n" + "\bLogEntry\x12\x1b\n" + "\tstep_uuid\x18\x01 \x01(\tR\bstepUuid\x12\x12\n" + "\x04time\x18\x02 \x01(\x03R\x04time\x12\x12\n" + "\x04line\x18\x03 \x01(\x05R\x04line\x12\x12\n" + "\x04type\x18\x04 \x01(\x05R\x04type\x12\x12\n" + "\x04data\x18\x05 \x01(\fR\x04data\"v\n" + "\x06Filter\x121\n" + "\x06labels\x18\x01 \x03(\v2\x19.proto.Filter.LabelsEntryR\x06labels\x1a9\n" + "\vLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"N\n" + "\bWorkflow\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\atimeout\x18\x02 \x01(\x03R\atimeout\x12\x18\n" + "\apayload\x18\x03 \x01(\fR\apayload\"4\n" + "\vNextRequest\x12%\n" + "\x06filter\x18\x01 \x01(\v2\r.proto.FilterR\x06filter\"I\n" + "\vInitRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12*\n" + "\x05state\x18\x02 \x01(\v2\x14.proto.WorkflowStateR\x05state\"\x1d\n" + "\vWaitRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"I\n" + "\vDoneRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12*\n" + "\x05state\x18\x02 \x01(\v2\x14.proto.WorkflowStateR\x05state\"\x1f\n" + "\rExtendRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"G\n" + "\rUpdateRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12&\n" + "\x05state\x18\x02 \x01(\v2\x10.proto.StepStateR\x05state\"=\n" + "\n" + "LogRequest\x12/\n" + "\n" + "logEntries\x18\x01 \x03(\v2\x0f.proto.LogEntryR\n" + "logEntries\"\a\n" + "\x05Empty\"-\n" + "\x13ReportHealthRequest\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\"\x80\x02\n" + "\tAgentInfo\x12\x1a\n" + "\bplatform\x18\x01 \x01(\tR\bplatform\x12\x1a\n" + "\bcapacity\x18\x02 \x01(\x05R\bcapacity\x12\x18\n" + "\abackend\x18\x03 \x01(\tR\abackend\x12\x18\n" + "\aversion\x18\x04 \x01(\tR\aversion\x12F\n" + "\fcustomLabels\x18\x05 \x03(\v2\".proto.AgentInfo.CustomLabelsEntryR\fcustomLabels\x1a?\n" + "\x11CustomLabelsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"<\n" + "\x14RegisterAgentRequest\x12$\n" + "\x04info\x18\x01 \x01(\v2\x10.proto.AgentInfoR\x04info\"[\n" + "\x0fVersionResponse\x12!\n" + "\fgrpc_version\x18\x01 \x01(\x05R\vgrpcVersion\x12%\n" + "\x0eserver_version\x18\x02 \x01(\tR\rserverVersion\";\n" + "\fNextResponse\x12+\n" + "\bworkflow\x18\x01 \x01(\v2\x0f.proto.WorkflowR\bworkflow\"2\n" + "\x15RegisterAgentResponse\x12\x19\n" + "\bagent_id\x18\x01 \x01(\x03R\aagentId\"*\n" + "\fWaitResponse\x12\x1a\n" + "\bcanceled\x18\x01 \x01(\bR\bcanceled\"I\n" + "\vAuthRequest\x12\x1f\n" + "\vagent_token\x18\x01 \x01(\tR\n" + "agentToken\x12\x19\n" + "\bagent_id\x18\x02 \x01(\x03R\aagentId\"d\n" + "\fAuthResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\x12\x19\n" + "\bagent_id\x18\x02 \x01(\x03R\aagentId\x12!\n" + "\faccess_token\x18\x03 \x01(\tR\vaccessToken2\xc2\x04\n" + "\n" + "Woodpecker\x121\n" + "\aVersion\x12\f.proto.Empty\x1a\x16.proto.VersionResponse\"\x00\x121\n" + "\x04Next\x12\x12.proto.NextRequest\x1a\x13.proto.NextResponse\"\x00\x12*\n" + "\x04Init\x12\x12.proto.InitRequest\x1a\f.proto.Empty\"\x00\x121\n" + "\x04Wait\x12\x12.proto.WaitRequest\x1a\x13.proto.WaitResponse\"\x00\x12*\n" + "\x04Done\x12\x12.proto.DoneRequest\x1a\f.proto.Empty\"\x00\x12.\n" + "\x06Extend\x12\x14.proto.ExtendRequest\x1a\f.proto.Empty\"\x00\x12.\n" + "\x06Update\x12\x14.proto.UpdateRequest\x1a\f.proto.Empty\"\x00\x12(\n" + "\x03Log\x12\x11.proto.LogRequest\x1a\f.proto.Empty\"\x00\x12L\n" + "\rRegisterAgent\x12\x1b.proto.RegisterAgentRequest\x1a\x1c.proto.RegisterAgentResponse\"\x00\x12/\n" + "\x0fUnregisterAgent\x12\f.proto.Empty\x1a\f.proto.Empty\"\x00\x12:\n" + "\fReportHealth\x12\x1a.proto.ReportHealthRequest\x1a\f.proto.Empty\"\x002C\n" + "\x0eWoodpeckerAuth\x121\n" + "\x04Auth\x12\x12.proto.AuthRequest\x1a\x13.proto.AuthResponse\"\x00B.Z,go.woodpecker-ci.org/woodpecker/v3/rpc/protob\x06proto3" var ( file_woodpecker_proto_rawDescOnce sync.Once file_woodpecker_proto_rawDescData []byte ) func file_woodpecker_proto_rawDescGZIP() []byte { file_woodpecker_proto_rawDescOnce.Do(func() { file_woodpecker_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_woodpecker_proto_rawDesc), len(file_woodpecker_proto_rawDesc))) }) return file_woodpecker_proto_rawDescData } var file_woodpecker_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_woodpecker_proto_goTypes = []any{ (*StepState)(nil), // 0: proto.StepState (*WorkflowState)(nil), // 1: proto.WorkflowState (*LogEntry)(nil), // 2: proto.LogEntry (*Filter)(nil), // 3: proto.Filter (*Workflow)(nil), // 4: proto.Workflow (*NextRequest)(nil), // 5: proto.NextRequest (*InitRequest)(nil), // 6: proto.InitRequest (*WaitRequest)(nil), // 7: proto.WaitRequest (*DoneRequest)(nil), // 8: proto.DoneRequest (*ExtendRequest)(nil), // 9: proto.ExtendRequest (*UpdateRequest)(nil), // 10: proto.UpdateRequest (*LogRequest)(nil), // 11: proto.LogRequest (*Empty)(nil), // 12: proto.Empty (*ReportHealthRequest)(nil), // 13: proto.ReportHealthRequest (*AgentInfo)(nil), // 14: proto.AgentInfo (*RegisterAgentRequest)(nil), // 15: proto.RegisterAgentRequest (*VersionResponse)(nil), // 16: proto.VersionResponse (*NextResponse)(nil), // 17: proto.NextResponse (*RegisterAgentResponse)(nil), // 18: proto.RegisterAgentResponse (*WaitResponse)(nil), // 19: proto.WaitResponse (*AuthRequest)(nil), // 20: proto.AuthRequest (*AuthResponse)(nil), // 21: proto.AuthResponse nil, // 22: proto.Filter.LabelsEntry nil, // 23: proto.AgentInfo.CustomLabelsEntry } var file_woodpecker_proto_depIdxs = []int32{ 22, // 0: proto.Filter.labels:type_name -> proto.Filter.LabelsEntry 3, // 1: proto.NextRequest.filter:type_name -> proto.Filter 1, // 2: proto.InitRequest.state:type_name -> proto.WorkflowState 1, // 3: proto.DoneRequest.state:type_name -> proto.WorkflowState 0, // 4: proto.UpdateRequest.state:type_name -> proto.StepState 2, // 5: proto.LogRequest.logEntries:type_name -> proto.LogEntry 23, // 6: proto.AgentInfo.customLabels:type_name -> proto.AgentInfo.CustomLabelsEntry 14, // 7: proto.RegisterAgentRequest.info:type_name -> proto.AgentInfo 4, // 8: proto.NextResponse.workflow:type_name -> proto.Workflow 12, // 9: proto.Woodpecker.Version:input_type -> proto.Empty 5, // 10: proto.Woodpecker.Next:input_type -> proto.NextRequest 6, // 11: proto.Woodpecker.Init:input_type -> proto.InitRequest 7, // 12: proto.Woodpecker.Wait:input_type -> proto.WaitRequest 8, // 13: proto.Woodpecker.Done:input_type -> proto.DoneRequest 9, // 14: proto.Woodpecker.Extend:input_type -> proto.ExtendRequest 10, // 15: proto.Woodpecker.Update:input_type -> proto.UpdateRequest 11, // 16: proto.Woodpecker.Log:input_type -> proto.LogRequest 15, // 17: proto.Woodpecker.RegisterAgent:input_type -> proto.RegisterAgentRequest 12, // 18: proto.Woodpecker.UnregisterAgent:input_type -> proto.Empty 13, // 19: proto.Woodpecker.ReportHealth:input_type -> proto.ReportHealthRequest 20, // 20: proto.WoodpeckerAuth.Auth:input_type -> proto.AuthRequest 16, // 21: proto.Woodpecker.Version:output_type -> proto.VersionResponse 17, // 22: proto.Woodpecker.Next:output_type -> proto.NextResponse 12, // 23: proto.Woodpecker.Init:output_type -> proto.Empty 19, // 24: proto.Woodpecker.Wait:output_type -> proto.WaitResponse 12, // 25: proto.Woodpecker.Done:output_type -> proto.Empty 12, // 26: proto.Woodpecker.Extend:output_type -> proto.Empty 12, // 27: proto.Woodpecker.Update:output_type -> proto.Empty 12, // 28: proto.Woodpecker.Log:output_type -> proto.Empty 18, // 29: proto.Woodpecker.RegisterAgent:output_type -> proto.RegisterAgentResponse 12, // 30: proto.Woodpecker.UnregisterAgent:output_type -> proto.Empty 12, // 31: proto.Woodpecker.ReportHealth:output_type -> proto.Empty 21, // 32: proto.WoodpeckerAuth.Auth:output_type -> proto.AuthResponse 21, // [21:33] is the sub-list for method output_type 9, // [9:21] is the sub-list for method input_type 9, // [9:9] is the sub-list for extension type_name 9, // [9:9] is the sub-list for extension extendee 0, // [0:9] is the sub-list for field type_name } func init() { file_woodpecker_proto_init() } func file_woodpecker_proto_init() { if File_woodpecker_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_woodpecker_proto_rawDesc), len(file_woodpecker_proto_rawDesc)), NumEnums: 0, NumMessages: 24, NumExtensions: 0, NumServices: 2, }, GoTypes: file_woodpecker_proto_goTypes, DependencyIndexes: file_woodpecker_proto_depIdxs, MessageInfos: file_woodpecker_proto_msgTypes, }.Build() File_woodpecker_proto = out.File file_woodpecker_proto_goTypes = nil file_woodpecker_proto_depIdxs = nil } ================================================ FILE: rpc/proto/woodpecker.proto ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2011 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; option go_package = "go.woodpecker-ci.org/woodpecker/v3/rpc/proto"; package proto; // !IMPORTANT! // Increased Version in version.go by 1 if you change something here! // !IMPORTANT! // Woodpecker Server Service service Woodpecker { rpc Version (Empty) returns (VersionResponse) {} rpc Next (NextRequest) returns (NextResponse) {} rpc Init (InitRequest) returns (Empty) {} rpc Wait (WaitRequest) returns (WaitResponse) {} rpc Done (DoneRequest) returns (Empty) {} rpc Extend (ExtendRequest) returns (Empty) {} rpc Update (UpdateRequest) returns (Empty) {} rpc Log (LogRequest) returns (Empty) {} rpc RegisterAgent (RegisterAgentRequest) returns (RegisterAgentResponse) {} rpc UnregisterAgent (Empty) returns (Empty) {} rpc ReportHealth (ReportHealthRequest) returns (Empty) {} } // // Basic Types // message StepState { string step_uuid = 1; int64 started = 2; int64 finished = 3; bool exited = 4; int32 exit_code = 5; string error = 6; bool canceled = 7; bool skipped = 8; } message WorkflowState { int64 started = 1; int64 finished = 2; string error = 3; bool canceled = 4; } message LogEntry { string step_uuid = 1; int64 time = 2; int32 line = 3; int32 type = 4; // 0 = stdout, 1 = stderr, 2 = exit-code, 3 = metadata, 4 = progress bytes data = 5; } message Filter { map labels = 1; } message Workflow { string id = 1; int64 timeout = 2; bytes payload = 3; } // // Request types // message NextRequest { Filter filter = 1; } message InitRequest { string id = 1; WorkflowState state = 2; } message WaitRequest { string id = 1; } message DoneRequest { string id = 1; WorkflowState state = 2; } message ExtendRequest { string id = 1; } message UpdateRequest { string id = 1; StepState state = 2; } message LogRequest { repeated LogEntry logEntries = 1; } message Empty { } message ReportHealthRequest { string status = 1; } message AgentInfo { string platform = 1; int32 capacity = 2; string backend = 3; string version = 4; map customLabels = 5; } message RegisterAgentRequest { AgentInfo info = 1; } // // Response types // message VersionResponse { int32 grpc_version = 1; string server_version = 2; } message NextResponse { Workflow workflow = 1; } message RegisterAgentResponse { int64 agent_id = 1; } message WaitResponse { bool canceled = 1; }; // Woodpecker auth service is a simple service to authenticate agents and acquire a token service WoodpeckerAuth { rpc Auth (AuthRequest) returns (AuthResponse) {} } message AuthRequest { string agent_token = 1; int64 agent_id = 2; } message AuthResponse { string status = 1; int64 agent_id = 2; string access_token = 3; } ================================================ FILE: rpc/proto/woodpecker_grpc.pb.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2011 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.1 // - protoc v6.33.1 // source: woodpecker.proto package proto import ( context "context" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" ) // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. // Requires gRPC-Go v1.64.0 or later. const _ = grpc.SupportPackageIsVersion9 const ( Woodpecker_Version_FullMethodName = "/proto.Woodpecker/Version" Woodpecker_Next_FullMethodName = "/proto.Woodpecker/Next" Woodpecker_Init_FullMethodName = "/proto.Woodpecker/Init" Woodpecker_Wait_FullMethodName = "/proto.Woodpecker/Wait" Woodpecker_Done_FullMethodName = "/proto.Woodpecker/Done" Woodpecker_Extend_FullMethodName = "/proto.Woodpecker/Extend" Woodpecker_Update_FullMethodName = "/proto.Woodpecker/Update" Woodpecker_Log_FullMethodName = "/proto.Woodpecker/Log" Woodpecker_RegisterAgent_FullMethodName = "/proto.Woodpecker/RegisterAgent" Woodpecker_UnregisterAgent_FullMethodName = "/proto.Woodpecker/UnregisterAgent" Woodpecker_ReportHealth_FullMethodName = "/proto.Woodpecker/ReportHealth" ) // WoodpeckerClient is the client API for Woodpecker service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // Woodpecker Server Service type WoodpeckerClient interface { Version(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VersionResponse, error) Next(ctx context.Context, in *NextRequest, opts ...grpc.CallOption) (*NextResponse, error) Init(ctx context.Context, in *InitRequest, opts ...grpc.CallOption) (*Empty, error) Wait(ctx context.Context, in *WaitRequest, opts ...grpc.CallOption) (*WaitResponse, error) Done(ctx context.Context, in *DoneRequest, opts ...grpc.CallOption) (*Empty, error) Extend(ctx context.Context, in *ExtendRequest, opts ...grpc.CallOption) (*Empty, error) Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*Empty, error) Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*Empty, error) RegisterAgent(ctx context.Context, in *RegisterAgentRequest, opts ...grpc.CallOption) (*RegisterAgentResponse, error) UnregisterAgent(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) ReportHealth(ctx context.Context, in *ReportHealthRequest, opts ...grpc.CallOption) (*Empty, error) } type woodpeckerClient struct { cc grpc.ClientConnInterface } func NewWoodpeckerClient(cc grpc.ClientConnInterface) WoodpeckerClient { return &woodpeckerClient{cc} } func (c *woodpeckerClient) Version(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VersionResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(VersionResponse) err := c.cc.Invoke(ctx, Woodpecker_Version_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Next(ctx context.Context, in *NextRequest, opts ...grpc.CallOption) (*NextResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(NextResponse) err := c.cc.Invoke(ctx, Woodpecker_Next_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Init(ctx context.Context, in *InitRequest, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_Init_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Wait(ctx context.Context, in *WaitRequest, opts ...grpc.CallOption) (*WaitResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(WaitResponse) err := c.cc.Invoke(ctx, Woodpecker_Wait_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Done(ctx context.Context, in *DoneRequest, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_Done_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Extend(ctx context.Context, in *ExtendRequest, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_Extend_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Update(ctx context.Context, in *UpdateRequest, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_Update_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) Log(ctx context.Context, in *LogRequest, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_Log_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) RegisterAgent(ctx context.Context, in *RegisterAgentRequest, opts ...grpc.CallOption) (*RegisterAgentResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RegisterAgentResponse) err := c.cc.Invoke(ctx, Woodpecker_RegisterAgent_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) UnregisterAgent(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_UnregisterAgent_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } func (c *woodpeckerClient) ReportHealth(ctx context.Context, in *ReportHealthRequest, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) err := c.cc.Invoke(ctx, Woodpecker_ReportHealth_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // WoodpeckerServer is the server API for Woodpecker service. // All implementations must embed UnimplementedWoodpeckerServer // for forward compatibility. // // Woodpecker Server Service type WoodpeckerServer interface { Version(context.Context, *Empty) (*VersionResponse, error) Next(context.Context, *NextRequest) (*NextResponse, error) Init(context.Context, *InitRequest) (*Empty, error) Wait(context.Context, *WaitRequest) (*WaitResponse, error) Done(context.Context, *DoneRequest) (*Empty, error) Extend(context.Context, *ExtendRequest) (*Empty, error) Update(context.Context, *UpdateRequest) (*Empty, error) Log(context.Context, *LogRequest) (*Empty, error) RegisterAgent(context.Context, *RegisterAgentRequest) (*RegisterAgentResponse, error) UnregisterAgent(context.Context, *Empty) (*Empty, error) ReportHealth(context.Context, *ReportHealthRequest) (*Empty, error) mustEmbedUnimplementedWoodpeckerServer() } // UnimplementedWoodpeckerServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedWoodpeckerServer struct{} func (UnimplementedWoodpeckerServer) Version(context.Context, *Empty) (*VersionResponse, error) { return nil, status.Error(codes.Unimplemented, "method Version not implemented") } func (UnimplementedWoodpeckerServer) Next(context.Context, *NextRequest) (*NextResponse, error) { return nil, status.Error(codes.Unimplemented, "method Next not implemented") } func (UnimplementedWoodpeckerServer) Init(context.Context, *InitRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method Init not implemented") } func (UnimplementedWoodpeckerServer) Wait(context.Context, *WaitRequest) (*WaitResponse, error) { return nil, status.Error(codes.Unimplemented, "method Wait not implemented") } func (UnimplementedWoodpeckerServer) Done(context.Context, *DoneRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method Done not implemented") } func (UnimplementedWoodpeckerServer) Extend(context.Context, *ExtendRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method Extend not implemented") } func (UnimplementedWoodpeckerServer) Update(context.Context, *UpdateRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method Update not implemented") } func (UnimplementedWoodpeckerServer) Log(context.Context, *LogRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method Log not implemented") } func (UnimplementedWoodpeckerServer) RegisterAgent(context.Context, *RegisterAgentRequest) (*RegisterAgentResponse, error) { return nil, status.Error(codes.Unimplemented, "method RegisterAgent not implemented") } func (UnimplementedWoodpeckerServer) UnregisterAgent(context.Context, *Empty) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method UnregisterAgent not implemented") } func (UnimplementedWoodpeckerServer) ReportHealth(context.Context, *ReportHealthRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method ReportHealth not implemented") } func (UnimplementedWoodpeckerServer) mustEmbedUnimplementedWoodpeckerServer() {} func (UnimplementedWoodpeckerServer) testEmbeddedByValue() {} // UnsafeWoodpeckerServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to WoodpeckerServer will // result in compilation errors. type UnsafeWoodpeckerServer interface { mustEmbedUnimplementedWoodpeckerServer() } func RegisterWoodpeckerServer(s grpc.ServiceRegistrar, srv WoodpeckerServer) { // If the following call panics, it indicates UnimplementedWoodpeckerServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&Woodpecker_ServiceDesc, srv) } func _Woodpecker_Version_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Version(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Version_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Version(ctx, req.(*Empty)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Next_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(NextRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Next(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Next_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Next(ctx, req.(*NextRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Init_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(InitRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Init(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Init_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Init(ctx, req.(*InitRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Wait_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(WaitRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Wait(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Wait_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Wait(ctx, req.(*WaitRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Done_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(DoneRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Done(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Done_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Done(ctx, req.(*DoneRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Extend_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ExtendRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Extend(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Extend_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Extend(ctx, req.(*ExtendRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Update_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(UpdateRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Update(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Update_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Update(ctx, req.(*UpdateRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_Log_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(LogRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).Log(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_Log_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).Log(ctx, req.(*LogRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_RegisterAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RegisterAgentRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).RegisterAgent(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_RegisterAgent_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).RegisterAgent(ctx, req.(*RegisterAgentRequest)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_UnregisterAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Empty) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).UnregisterAgent(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_UnregisterAgent_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).UnregisterAgent(ctx, req.(*Empty)) } return interceptor(ctx, in, info, handler) } func _Woodpecker_ReportHealth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ReportHealthRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerServer).ReportHealth(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: Woodpecker_ReportHealth_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerServer).ReportHealth(ctx, req.(*ReportHealthRequest)) } return interceptor(ctx, in, info, handler) } // Woodpecker_ServiceDesc is the grpc.ServiceDesc for Woodpecker service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var Woodpecker_ServiceDesc = grpc.ServiceDesc{ ServiceName: "proto.Woodpecker", HandlerType: (*WoodpeckerServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "Version", Handler: _Woodpecker_Version_Handler, }, { MethodName: "Next", Handler: _Woodpecker_Next_Handler, }, { MethodName: "Init", Handler: _Woodpecker_Init_Handler, }, { MethodName: "Wait", Handler: _Woodpecker_Wait_Handler, }, { MethodName: "Done", Handler: _Woodpecker_Done_Handler, }, { MethodName: "Extend", Handler: _Woodpecker_Extend_Handler, }, { MethodName: "Update", Handler: _Woodpecker_Update_Handler, }, { MethodName: "Log", Handler: _Woodpecker_Log_Handler, }, { MethodName: "RegisterAgent", Handler: _Woodpecker_RegisterAgent_Handler, }, { MethodName: "UnregisterAgent", Handler: _Woodpecker_UnregisterAgent_Handler, }, { MethodName: "ReportHealth", Handler: _Woodpecker_ReportHealth_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "woodpecker.proto", } const ( WoodpeckerAuth_Auth_FullMethodName = "/proto.WoodpeckerAuth/Auth" ) // WoodpeckerAuthClient is the client API for WoodpeckerAuth service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type WoodpeckerAuthClient interface { Auth(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthResponse, error) } type woodpeckerAuthClient struct { cc grpc.ClientConnInterface } func NewWoodpeckerAuthClient(cc grpc.ClientConnInterface) WoodpeckerAuthClient { return &woodpeckerAuthClient{cc} } func (c *woodpeckerAuthClient) Auth(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(AuthResponse) err := c.cc.Invoke(ctx, WoodpeckerAuth_Auth_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } return out, nil } // WoodpeckerAuthServer is the server API for WoodpeckerAuth service. // All implementations must embed UnimplementedWoodpeckerAuthServer // for forward compatibility. type WoodpeckerAuthServer interface { Auth(context.Context, *AuthRequest) (*AuthResponse, error) mustEmbedUnimplementedWoodpeckerAuthServer() } // UnimplementedWoodpeckerAuthServer must be embedded to have // forward compatible implementations. // // NOTE: this should be embedded by value instead of pointer to avoid a nil // pointer dereference when methods are called. type UnimplementedWoodpeckerAuthServer struct{} func (UnimplementedWoodpeckerAuthServer) Auth(context.Context, *AuthRequest) (*AuthResponse, error) { return nil, status.Error(codes.Unimplemented, "method Auth not implemented") } func (UnimplementedWoodpeckerAuthServer) mustEmbedUnimplementedWoodpeckerAuthServer() {} func (UnimplementedWoodpeckerAuthServer) testEmbeddedByValue() {} // UnsafeWoodpeckerAuthServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to WoodpeckerAuthServer will // result in compilation errors. type UnsafeWoodpeckerAuthServer interface { mustEmbedUnimplementedWoodpeckerAuthServer() } func RegisterWoodpeckerAuthServer(s grpc.ServiceRegistrar, srv WoodpeckerAuthServer) { // If the following call panics, it indicates UnimplementedWoodpeckerAuthServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { t.testEmbeddedByValue() } s.RegisterService(&WoodpeckerAuth_ServiceDesc, srv) } func _WoodpeckerAuth_Auth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(AuthRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { return srv.(WoodpeckerAuthServer).Auth(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, FullMethod: WoodpeckerAuth_Auth_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(WoodpeckerAuthServer).Auth(ctx, req.(*AuthRequest)) } return interceptor(ctx, in, info, handler) } // WoodpeckerAuth_ServiceDesc is the grpc.ServiceDesc for WoodpeckerAuth service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) var WoodpeckerAuth_ServiceDesc = grpc.ServiceDesc{ ServiceName: "proto.WoodpeckerAuth", HandlerType: (*WoodpeckerAuthServer)(nil), Methods: []grpc.MethodDesc{ { MethodName: "Auth", Handler: _WoodpeckerAuth_Auth_Handler, }, }, Streams: []grpc.StreamDesc{}, Metadata: "woodpecker.proto", } ================================================ FILE: rpc/types.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" ) type ( // Filter defines filters for fetching items from the queue. Filter struct { Labels map[string]string `json:"labels"` } // StepState defines the step state. StepState struct { StepUUID string `json:"step_uuid"` Started int64 `json:"started"` Finished int64 `json:"finished"` Exited bool `json:"exited"` ExitCode int `json:"exit_code"` Error string `json:"error"` Canceled bool `json:"canceled"` Skipped bool `json:"skipped"` } // WorkflowState defines the workflow state. WorkflowState struct { Started int64 `json:"started"` Finished int64 `json:"finished"` Error string `json:"error"` Canceled bool `json:"canceled"` } // Workflow defines the workflow execution details. Workflow struct { ID string `json:"id"` Config *backend_types.Config `json:"config"` Timeout int64 `json:"timeout"` } Version struct { GrpcVersion int32 `json:"grpc_version,omitempty"` ServerVersion string `json:"server_version,omitempty"` } // AgentInfo represents all the metadata that should be known about an agent. AgentInfo struct { Version string `json:"version"` Platform string `json:"platform"` Backend string `json:"backend"` Capacity int `json:"capacity"` CustomLabels map[string]string `json:"custom_labels"` } ) ================================================ FILE: server/api/agent.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "strconv" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // // Global Agents. // // GetAgents // // @Summary List agents // @Router /agents [get] // @Produce json // @Success 200 {array} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetAgents(c *gin.Context) { agents, err := store.FromContext(c).AgentList(session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting agent list. %s", err) return } c.JSON(http.StatusOK, agents) } // GetAgent // // @Summary Get an agent // @Router /agents/{agent_id} [get] // @Produce json // @Success 200 {object} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent_id path int true "the agent's id" func GetAgent(c *gin.Context) { agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } agent, err := store.FromContext(c).AgentFind(agentID) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, agent) } // GetAgentTasks // // @Summary List agent tasks // @Router /agents/{agent_id}/tasks [get] // @Produce json // @Success 200 {array} Task // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent_id path int true "the agent's id" func GetAgentTasks(c *gin.Context) { agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } agent, err := store.FromContext(c).AgentFind(agentID) if err != nil { handleDBError(c, err) return } var tasks []*model.Task info := server.Config.Services.Scheduler.Info(c) for _, task := range info.Running { if task.AgentID == agent.ID { tasks = append(tasks, task) } } c.JSON(http.StatusOK, tasks) } // PatchAgent // // @Summary Update an agent // @Router /agents/{agent_id} [patch] // @Produce json // @Success 200 {object} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent_id path int true "the agent's id" // @Param agentData body Agent true "the agent's data" func PatchAgent(c *gin.Context) { _store := store.FromContext(c) in := &model.Agent{} err := c.Bind(in) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } agent, err := _store.AgentFind(agentID) if err != nil { handleDBError(c, err) return } // Update allowed fields agent.Name = in.Name agent.NoSchedule = in.NoSchedule if agent.NoSchedule { server.Config.Services.Scheduler.KickAgentWorkers(agent.ID) } err = _store.AgentUpdate(agent) if err != nil { c.AbortWithStatus(http.StatusConflict) return } c.JSON(http.StatusOK, agent) } // PostAgent // // @Summary Create a new agent // @Description Creates a new agent with a random token // @Router /agents [post] // @Produce json // @Success 200 {object} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent body Agent true "the agent's data (only 'name' and 'no_schedule' are read)" func PostAgent(c *gin.Context) { in := &model.Agent{} err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, err.Error()) return } user := session.User(c) agent := &model.Agent{ Name: in.Name, OwnerID: user.ID, OrgID: model.IDNotSet, NoSchedule: in.NoSchedule, Token: model.GenerateNewAgentToken(), } if err = store.FromContext(c).AgentCreate(agent); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, agent) } // DeleteAgent // // @Summary Delete an agent // @Router /agents/{agent_id} [delete] // @Produce plain // @Success 200 // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param agent_id path int true "the agent's id" func DeleteAgent(c *gin.Context) { _store := store.FromContext(c) agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } agent, err := _store.AgentFind(agentID) if err != nil { handleDBError(c, err) return } // prevent deletion of agents with running tasks info := server.Config.Services.Scheduler.Info(c) for _, task := range info.Running { if task.AgentID == agent.ID { c.String(http.StatusConflict, "Agent has running tasks") return } } // kick workers to remove the agent from the queue server.Config.Services.Scheduler.KickAgentWorkers(agent.ID) if err = _store.AgentDelete(agent); err != nil { c.String(http.StatusInternalServerError, "Error deleting user. %s", err) return } c.Status(http.StatusNoContent) } // // Org/User Agents. // // PostOrgAgent // // @Summary Create a new organization-scoped agent // @Description Creates a new agent with a random token, scoped to the specified organization // @Router /orgs/{org_id}/agents [post] // @Produce json // @Success 200 {object} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path int true "the organization's id" // @Param agent body Agent true "the agent's data (only 'name' and 'no_schedule' are read)" func PostOrgAgent(c *gin.Context) { _store := store.FromContext(c) user := session.User(c) orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Invalid organization ID") return } in := new(model.Agent) err = c.Bind(in) if err != nil { c.String(http.StatusBadRequest, err.Error()) return } agent := &model.Agent{ Name: in.Name, OwnerID: user.ID, OrgID: orgID, NoSchedule: in.NoSchedule, Token: model.GenerateNewAgentToken(), } if err = _store.AgentCreate(agent); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, agent) } // GetOrgAgents // // @Summary List agents for an organization // @Router /orgs/{org_id}/agents [get] // @Produce json // @Success 200 {array} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path int true "the organization's id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetOrgAgents(c *gin.Context) { _store := store.FromContext(c) org := session.Org(c) agents, err := _store.AgentListForOrg(org.ID, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting agent list. %s", err) return } c.JSON(http.StatusOK, agents) } // PatchOrgAgent // // @Summary Update an organization-scoped agent // @Router /orgs/{org_id}/agents/{agent_id} [patch] // @Produce json // @Success 200 {object} Agent // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path int true "the organization's id" // @Param agent_id path int true "the agent's id" // @Param agent body Agent true "the agent's updated data" func PatchOrgAgent(c *gin.Context) { _store := store.FromContext(c) org := session.Org(c) agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Invalid agent ID") return } agent, err := _store.AgentFind(agentID) if err != nil { c.String(http.StatusNotFound, "Agent not found") return } if agent.OrgID != org.ID { c.String(http.StatusNotFound, "Agent not found") return } in := new(model.Agent) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, err.Error()) return } // Update allowed fields agent.Name = in.Name agent.NoSchedule = in.NoSchedule if agent.NoSchedule { server.Config.Services.Scheduler.KickAgentWorkers(agent.ID) } if err := _store.AgentUpdate(agent); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, agent) } // DeleteOrgAgent // // @Summary Delete an organization-scoped agent // @Router /orgs/{org_id}/agents/{agent_id} [delete] // @Produce plain // @Success 204 // @Tags Agents // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path int true "the organization's id" // @Param agent_id path int true "the agent's id" func DeleteOrgAgent(c *gin.Context) { _store := store.FromContext(c) org := session.Org(c) agentID, err := strconv.ParseInt(c.Param("agent_id"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Invalid agent ID") return } agent, err := _store.AgentFind(agentID) if err != nil { c.String(http.StatusNotFound, "Agent not found") return } if agent.OrgID != org.ID { c.String(http.StatusNotFound, "Agent not found") return } // Check if the agent has any running tasks info := server.Config.Services.Scheduler.Info(c) for _, task := range info.Running { if task.AgentID == agent.ID { c.String(http.StatusConflict, "Agent has running tasks") return } } // Kick workers to remove the agent from the queue server.Config.Services.Scheduler.KickAgentWorkers(agent.ID) if err := _store.AgentDelete(agent); err != nil { c.String(http.StatusInternalServerError, "Error deleting agent. %s", err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/agent_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" "go.woodpecker-ci.org/woodpecker/v3/server/queue" queue_mocks "go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) var fakeAgent = &model.Agent{ ID: 1, Name: "test-agent", OwnerID: 1, NoSchedule: false, } func TestGetAgents(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should get agents", func(t *testing.T) { agents := []*model.Agent{fakeAgent} mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentList", mock.Anything).Return(agents, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) GetAgents(c) c.Writer.WriteHeaderNow() mockStore.AssertCalled(t, "AgentList", mock.Anything) assert.Equal(t, http.StatusOK, w.Code) var response []*model.Agent err := json.Unmarshal(w.Body.Bytes(), &response) assert.NoError(t, err) assert.Equal(t, agents, response) }) } func TestGetAgent(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should get agent", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Params = gin.Params{{Key: "agent_id", Value: "1"}} GetAgent(c) c.Writer.WriteHeaderNow() mockStore.AssertCalled(t, "AgentFind", int64(1)) assert.Equal(t, http.StatusOK, w.Code) var response model.Agent err := json.Unmarshal(w.Body.Bytes(), &response) assert.NoError(t, err) assert.Equal(t, fakeAgent, &response) }) t.Run("should return bad request for invalid agent id", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "agent_id", Value: "invalid"}} GetAgent(c) c.Writer.WriteHeaderNow() assert.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("should return not found for non-existent agent", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentFind", int64(2)).Return((*model.Agent)(nil), types.ErrRecordNotExist) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Params = gin.Params{{Key: "agent_id", Value: "2"}} GetAgent(c) c.Writer.WriteHeaderNow() assert.Equal(t, http.StatusNotFound, w.Code) }) } func TestPatchAgent(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should update agent", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) mockStore.On("AgentUpdate", mock.AnythingOfType("*model.Agent")).Return(nil) mockManager := manager_mocks.NewMockManager(t) server.Config.Services.Manager = mockManager w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Params = gin.Params{{Key: "agent_id", Value: "1"}} c.Request, _ = http.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"name":"updated-agent"}`)) c.Request.Header.Set("Content-Type", "application/json") PatchAgent(c) c.Writer.WriteHeaderNow() mockStore.AssertCalled(t, "AgentFind", int64(1)) mockStore.AssertCalled(t, "AgentUpdate", mock.AnythingOfType("*model.Agent")) assert.Equal(t, http.StatusOK, w.Code) var response model.Agent err := json.Unmarshal(w.Body.Bytes(), &response) assert.NoError(t, err) assert.Equal(t, "updated-agent", response.Name) }) } func TestPostAgent(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should create agent", func(t *testing.T) { newAgent := &model.Agent{ Name: "new-agent", } mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentCreate", mock.AnythingOfType("*model.Agent")).Return(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Set("user", &model.User{ID: 1}) c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"new-agent"}`)) c.Request.Header.Set("Content-Type", "application/json") PostAgent(c) c.Writer.WriteHeaderNow() mockStore.AssertCalled(t, "AgentCreate", mock.AnythingOfType("*model.Agent")) assert.Equal(t, http.StatusOK, w.Code) var response model.Agent err := json.Unmarshal(w.Body.Bytes(), &response) assert.NoError(t, err) assert.Equal(t, newAgent.Name, response.Name) assert.NotEmpty(t, response.Token) }) } func TestDeleteAgent(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should delete agent", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) mockStore.On("AgentDelete", mock.AnythingOfType("*model.Agent")).Return(nil) mockManager := manager_mocks.NewMockManager(t) server.Config.Services.Manager = mockManager mockQueue := queue_mocks.NewMockQueue(t) mockQueue.On("Info", mock.Anything).Return(queue.InfoT{}) mockQueue.On("KickAgentWorkers", int64(1)).Return() server.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New()) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Params = gin.Params{{Key: "agent_id", Value: "1"}} DeleteAgent(c) c.Writer.WriteHeaderNow() mockStore.AssertCalled(t, "AgentFind", int64(1)) mockStore.AssertCalled(t, "AgentDelete", mock.AnythingOfType("*model.Agent")) mockQueue.AssertCalled(t, "KickAgentWorkers", int64(1)) assert.Equal(t, http.StatusNoContent, w.Code) }) t.Run("should not delete agent with running tasks", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) mockManager := manager_mocks.NewMockManager(t) server.Config.Services.Manager = mockManager mockQueue := queue_mocks.NewMockQueue(t) mockQueue.On("Info", mock.Anything).Return(queue.InfoT{ Running: []*model.Task{{AgentID: 1}}, }) server.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New()) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Params = gin.Params{{Key: "agent_id", Value: "1"}} DeleteAgent(c) c.Writer.WriteHeaderNow() mockStore.AssertCalled(t, "AgentFind", int64(1)) mockStore.AssertNotCalled(t, "AgentDelete", mock.Anything) assert.Equal(t, http.StatusConflict, w.Code) }) } func TestPostOrgAgent(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("create org agent should succeed", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("AgentCreate", mock.AnythingOfType("*model.Agent")).Return(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) // Set up a non-admin user c.Set("user", &model.User{ ID: 1, Admin: false, }) c.Params = gin.Params{{Key: "org_id", Value: "1"}} c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"new-agent"}`)) c.Request.Header.Set("Content-Type", "application/json") PostOrgAgent(c) c.Writer.WriteHeaderNow() assert.Equal(t, http.StatusOK, w.Code) // Ensure an agent was created mockStore.AssertCalled(t, "AgentCreate", mock.AnythingOfType("*model.Agent")) }) } ================================================ FILE: server/api/badge.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "errors" "fmt" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/badges" "go.woodpecker-ci.org/woodpecker/v3/server/ccmenu" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) // GetBadge // // @Summary Get status of pipeline as SVG badge // @Router /badges/{repo_id}/status.svg [get] // @Produce image/svg+xml // @Success 200 // @Tags Badges // @Param repo_id path int true "the repository id" func GetBadge(c *gin.Context) { _store := store.FromContext(c) var repo *model.Repo var err error if c.Param("repo_name") != "" { repo, err = _store.GetRepoName(c.Param("repo_id_or_owner") + "/" + c.Param("repo_name")) } else { var repoID int64 repoID, err = strconv.ParseInt(c.Param("repo_id_or_owner"), 10, 64) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } repo, err = _store.GetRepo(repoID) } if err != nil { handleDBError(c, err) return } // if no commit was found then display // the 'none' badge, instead of throwing // an error response branch := c.Query("branch") if len(branch) == 0 { branch = repo.Branch } // Events to lookup, multiple separated by comma var events []model.WebhookEvent eventsQuery := c.Query("events") // If none given, fallback to default "push" if len(eventsQuery) == 0 { events = []model.WebhookEvent{model.EventPush} } else { strEvents := strings.Split(eventsQuery, ",") events = make([]model.WebhookEvent, len(strEvents)) for i, strEvent := range strEvents { event := model.WebhookEvent(strEvent) if err := event.Validate(); err == nil { events[i] = event } else { _ = c.AbortWithError(http.StatusBadRequest, err) return } } } name := "pipeline" var status *model.StatusValue = nil pl, err := _store.GetPipelineBadge(repo, branch, events) if err != nil { if !errors.Is(err, types.ErrRecordNotExist) { log.Warn().Err(err).Msg("could not get last pipeline for badge") } } else { status = &pl.Status } // we serve an SVG, so set content type appropriately. c.Writer.Header().Set("Content-Type", "image/svg+xml") // Allow workflow (and step) specific badges workflowName := c.Query("workflow") if len(workflowName) != 0 { name = workflowName status = nil workflows, err := _store.WorkflowGetTree(pl) if err == nil { for _, wf := range workflows { if wf.Name == workflowName { stepName := c.Query("step") if len(stepName) == 0 { if status != nil { merged := pipeline.MergeStatusValues(*status, wf.State) status = &merged } else { status = &wf.State } continue } // If step is explicitly requested name = workflowName + ": " + stepName for _, s := range wf.Children { if s.Name == stepName { if status != nil { merged := pipeline.MergeStatusValues(*status, s.State) status = &merged } else { status = &s.State } } } } } } } badge, err := badges.Generate(name, status) if err != nil { c.String(http.StatusInternalServerError, "Failed to generate badge.") } else { c.String(http.StatusOK, badge) } } // GetCC // // @Summary Provide pipeline status information to the CCMenu tool // @Description CCMenu displays the pipeline status of projects on a CI server as an item in the Mac's menu bar. // @Description More details on how to install, you can find at http://ccmenu.org/ // @Description The response format adheres to CCTray v1 Specification, https://cctray.org/v1/ // @Router /badges/{repo_id}/cc.xml [get] // @Produce xml // @Success 200 // @Tags Badges // @Param repo_id path int true "the repository id" func GetCC(c *gin.Context) { _store := store.FromContext(c) var repo *model.Repo var err error if c.Param("repo_name") != "" { repo, err = _store.GetRepoName(c.Param("repo_id_or_owner") + "/" + c.Param("repo_name")) } else { var repoID int64 repoID, err = strconv.ParseInt(c.Param("repo_id_or_owner"), 10, 64) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } repo, err = _store.GetRepo(repoID) } if err != nil { handleDBError(c, err) return } pipelines, err := _store.GetPipelineList(repo, &model.ListOptions{Page: 1, PerPage: 1}, nil) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { log.Warn().Err(err).Msg("could not get pipeline list") c.AbortWithStatus(http.StatusInternalServerError) return } if len(pipelines) == 0 { c.AbortWithStatus(http.StatusNotFound) return } url := fmt.Sprintf("%s/repos/%d/pipeline/%d", server.Config.Server.Host, repo.ID, pipelines[0].Number) cc := ccmenu.New(repo, pipelines[0], url) c.XML(http.StatusOK, cc) } ================================================ FILE: server/api/cron.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "errors" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" cron_scheduler "go.woodpecker-ci.org/woodpecker/v3/server/cron" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) // GetCron // // @Summary Get a cron job // @Router /repos/{repo_id}/cron/{cron} [get] // @Produce json // @Success 200 {object} Cron // @Tags Repository cron jobs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param cron path string true "the cron job id" func GetCron(c *gin.Context) { repo := session.Repo(c) id, err := strconv.ParseInt(c.Param("cron"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Error parsing cron id. %s", err) return } cron, err := store.FromContext(c).CronFind(repo, id) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, cron) } // RunCron // // @Summary Start a cron job now // @Router /repos/{repo_id}/cron/{cron} [post] // @Produce json // @Success 200 {object} Pipeline // @Tags Repository cron jobs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param cron path string true "the cron job id" func RunCron(c *gin.Context) { repo := session.Repo(c) _store := store.FromContext(c) id, err := strconv.ParseInt(c.Param("cron"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Error parsing cron id. %s", err) return } cron, err := _store.CronFind(repo, id) if err != nil { handleDBError(c, err) return } repo, newPipeline, err := cron_scheduler.CreatePipeline(c, _store, cron) if err != nil { c.String(http.StatusInternalServerError, "Error creating pipeline for cron %q. %s", id, err) return } pl, err := pipeline.Create(c, _store, repo, newPipeline) if err != nil { handlePipelineErr(c, err) return } c.JSON(http.StatusOK, pl) } // PostCron // // @Summary Create a cron job // @Router /repos/{repo_id}/cron [post] // @Produce json // @Success 200 {object} Cron // @Tags Repository cron jobs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param cronJob body Cron true "the new cron job" func PostCron(c *gin.Context) { repo := session.Repo(c) user := session.User(c) _store := store.FromContext(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") c.AbortWithStatus(http.StatusInternalServerError) return } in := new(model.Cron) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing request. %s", err) return } cron := &model.Cron{ RepoID: repo.ID, Name: in.Name, CreatorID: user.ID, Schedule: in.Schedule, Branch: in.Branch, Variables: in.Variables, Enabled: in.Enabled, } if err := cron.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error inserting cron. validate failed: %s", err) return } nextExec, err := cron_scheduler.CalcNewNext(in.Schedule, time.Now()) if err != nil { c.String(http.StatusBadRequest, "Error inserting cron. schedule could not parsed: %s", err) return } cron.NextExec = nextExec.Unix() if in.Branch != "" { // check if branch exists on forge _, err := _forge.BranchHead(c, user, repo, in.Branch) if err != nil { c.String(http.StatusBadRequest, "Error inserting cron. branch not resolved: %s", err) return } } if err := _store.CronCreate(cron); err != nil { if errors.Is(err, types.ErrInsertDuplicateDetected) { c.String(http.StatusConflict, "cron with this exists for this repo already") } else { c.String(http.StatusInternalServerError, "Error inserting cron %q. %s", in.Name, err) } return } c.JSON(http.StatusOK, cron) } // PatchCron // // @Summary Update a cron job // @Router /repos/{repo_id}/cron/{cron} [patch] // @Produce json // @Success 200 {object} Cron // @Tags Repository cron jobs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param cron path string true "the cron job id" // @Param cronJob body CronPatch true "the cron job data" func PatchCron(c *gin.Context) { repo := session.Repo(c) user := session.User(c) _store := store.FromContext(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") c.AbortWithStatus(http.StatusInternalServerError) return } id, err := strconv.ParseInt(c.Param("cron"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Error parsing cron id. %s", err) return } in := new(model.CronPatch) err = c.Bind(in) if err != nil { c.String(http.StatusBadRequest, "Error parsing request. %s", err) return } cron, err := _store.CronFind(repo, id) if err != nil { handleDBError(c, err) return } if in.Branch != nil && *in.Branch != "" { // check if branch exists on forge _, err := _forge.BranchHead(c, user, repo, *in.Branch) if err != nil { c.String(http.StatusBadRequest, "Error inserting cron. branch not resolved: %s", err) return } cron.Branch = *in.Branch } if in.Schedule != nil && *in.Schedule != "" { cron.Schedule = *in.Schedule nextExec, err := cron_scheduler.CalcNewNext(*in.Schedule, time.Now()) if err != nil { c.String(http.StatusBadRequest, "Error inserting cron. schedule could not parsed: %s", err) return } cron.NextExec = nextExec.Unix() } if in.Name != nil && *in.Name != "" { cron.Name = *in.Name } if in.Enabled != nil { cron.Enabled = *in.Enabled // if we re-enable a cron we have to calc NextExec because it was not while disabled if cron.Enabled { nextExec, err := cron_scheduler.CalcNewNext(*in.Schedule, time.Now()) if err != nil { c.String(http.StatusInternalServerError, "Cron schedule could not parsed: %s", err) return } cron.NextExec = nextExec.Unix() } } if in.Variables != nil { cron.Variables = in.Variables } cron.CreatorID = user.ID if err := cron.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error inserting cron. validate failed: %s", err) return } if err := _store.CronUpdate(repo, cron); err != nil { c.String(http.StatusInternalServerError, "Error updating cron %q. %s", in.Name, err) return } c.JSON(http.StatusOK, cron) } // GetCronList // // @Summary List cron jobs // @Router /repos/{repo_id}/cron [get] // @Produce json // @Success 200 {array} Cron // @Tags Repository cron jobs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetCronList(c *gin.Context) { repo := session.Repo(c) list, err := store.FromContext(c).CronList(repo, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting cron list. %s", err) return } c.JSON(http.StatusOK, list) } // DeleteCron // // @Summary Delete a cron job // @Router /repos/{repo_id}/cron/{cron} [delete] // @Produce plain // @Success 204 // @Tags Repository cron jobs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param cron path string true "the cron job id" func DeleteCron(c *gin.Context) { repo := session.Repo(c) id, err := strconv.ParseInt(c.Param("cron"), 10, 64) if err != nil { c.String(http.StatusBadRequest, "Error parsing cron id. %s", err) return } if err := store.FromContext(c).CronDelete(repo, id); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/debug/debug.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package debug import ( "net/http/pprof" "github.com/gin-gonic/gin" ) // IndexHandler // // @Summary List available pprof profiles (HTML) // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Router /debug/pprof [get] // @Produce html // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func IndexHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Index(c.Writer, c.Request) } } // HeapHandler // // @Summary Get pprof heap dump, a sampling of memory allocations of live objects // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Router /debug/pprof/heap [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param gc query string false "You can specify gc=heap to run GC before taking the heap sample" default() func HeapHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Handler("heap").ServeHTTP(c.Writer, c.Request) } } // GoroutineHandler // // @Summary Get pprof stack traces of all current goroutines // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Router /debug/pprof/goroutine [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param debug query int false "Use debug=2 as a query parameter to export in the same format as an un-recovered panic" default(1) func GoroutineHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Handler("goroutine").ServeHTTP(c.Writer, c.Request) } } // BlockHandler // // @Summary Get pprof stack traces that led to blocking on synchronization primitives // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Router /debug/pprof/block [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func BlockHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Handler("block").ServeHTTP(c.Writer, c.Request) } } // ThreadCreateHandler // // @Summary Get pprof stack traces that led to the creation of new OS threads // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Router /debug/pprof/threadcreate [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func ThreadCreateHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Handler("threadcreate").ServeHTTP(c.Writer, c.Request) } } // CmdlineHandler // // @Summary Get the command line invocation of the current program // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Router /debug/pprof/cmdline [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func CmdlineHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Cmdline(c.Writer, c.Request) } } // ProfileHandler // // @Summary Get pprof CPU profile // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Description After you get the profile file, use the go tool pprof command to investigate the profile. // @Router /debug/pprof/profile [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param seconds query int true "You can specify the duration in the seconds GET parameter." default (30) func ProfileHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Profile(c.Writer, c.Request) } } // SymbolHandler // // @Summary Get pprof program counters mapping to function names // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Description Looks up the program counters listed in the request, // @Description responding with a table mapping program counters to function names. // @Description The requested program counters can be provided via GET + query parameters, // @Description or POST + body parameters. Program counters shall be space delimited. // @Router /debug/pprof/symbol [get] // @Router /debug/pprof/symbol [post] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func SymbolHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Symbol(c.Writer, c.Request) } } // TraceHandler // // @Summary Get a trace of execution of the current program // @Description Only available, when server was started with WOODPECKER_LOG_LEVEL=debug // @Description After you get the profile file, use the go tool pprof command to investigate the profile. // @Router /debug/pprof/trace [get] // @Produce plain // @Success 200 // @Tags Process profiling and debugging // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param seconds query int true "You can specify the duration in the seconds GET parameter." default (30) func TraceHandler() gin.HandlerFunc { return func(c *gin.Context) { pprof.Trace(c.Writer, c.Request) } } ================================================ FILE: server/api/forge.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "strconv" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // GetForges // // @Summary List forges // @Router /forges [get] // @Produce json // @Success 200 {array} Forge // @Tags Forges // @Param Authorization header string false "Insert your personal access token" default(Bearer ) // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetForges(c *gin.Context) { forges, err := store.FromContext(c).ForgeList(session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting forge list. %s", err) return } user := session.User(c) if user != nil && user.Admin { c.JSON(http.StatusOK, forges) return } // copy forges data without sensitive information for i, forge := range forges { forges[i] = forge.PublicCopy() } c.JSON(http.StatusOK, forges) } // GetForge // // @Summary Get a forge // @Router /forges/{forge_id} [get] // @Produce json // @Success 200 {object} Forge // @Tags Forges // @Param Authorization header string false "Insert your personal access token" default(Bearer ) // @Param forge_id path int true "the forge's id" func GetForge(c *gin.Context) { forgeID, err := strconv.ParseInt(c.Param("forge_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } forge, err := store.FromContext(c).ForgeGet(forgeID) if err != nil { handleDBError(c, err) return } user := session.User(c) if user != nil && user.Admin { c.JSON(http.StatusOK, forge) } else { c.JSON(http.StatusOK, forge.PublicCopy()) } } // PatchForge // // @Summary Update a forge // @Router /forges/{forge_id} [patch] // @Produce json // @Success 200 {object} Forge // @Tags Forges // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param forge_id path int true "the forge's id" // @Param forgeData body ForgeWithOAuthClientSecret true "the forge's data" func PatchForge(c *gin.Context) { _store := store.FromContext(c) in := &model.ForgeWithOAuthClientSecret{} err := c.Bind(in) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } forgeID, err := strconv.ParseInt(c.Param("forge_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } forge, err := _store.ForgeGet(forgeID) if err != nil { handleDBError(c, err) return } forge.URL = in.URL forge.Type = in.Type forge.OAuthClientID = in.OAuthClientID forge.OAuthHost = in.OAuthHost forge.SkipVerify = in.SkipVerify forge.AdditionalOptions = in.AdditionalOptions if in.OAuthClientSecret != "" { forge.OAuthClientSecret = in.OAuthClientSecret } err = _store.ForgeUpdate(forge) if err != nil { c.AbortWithStatus(http.StatusConflict) return } c.JSON(http.StatusOK, forge) } // PostForge // // @Summary Create a new forge // @Description Creates a new forge with a random token // @Router /forges [post] // @Produce json // @Success 200 {object} Forge // @Tags Forges // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param forge body ForgeWithOAuthClientSecret true "the forge's data (only 'name' and 'no_schedule' are read)" func PostForge(c *gin.Context) { in := &model.ForgeWithOAuthClientSecret{} err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, err.Error()) return } forge := &model.Forge{ URL: in.URL, Type: in.Type, OAuthClientID: in.OAuthClientID, OAuthClientSecret: in.OAuthClientSecret, OAuthHost: in.OAuthHost, SkipVerify: in.SkipVerify, AdditionalOptions: in.AdditionalOptions, } if err = store.FromContext(c).ForgeCreate(forge); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, forge) } // DeleteForge // // @Summary Delete a forge // @Router /forges/{forge_id} [delete] // @Produce plain // @Success 200 // @Tags Forges // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param forge_id path int true "the forge's id" func DeleteForge(c *gin.Context) { _store := store.FromContext(c) forgeID, err := strconv.ParseInt(c.Param("forge_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } forge, err := _store.ForgeGet(forgeID) if err != nil { handleDBError(c, err) return } if err = _store.ForgeDelete(forge); err != nil { c.String(http.StatusInternalServerError, "Error deleting user. %s", err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/global_registry.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) // GetGlobalRegistryList // // @Summary List global registries // @Router /registries [get] // @Produce json // @Success 200 {array} Registry // @Tags Registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetGlobalRegistryList(c *gin.Context) { registryService := server.Config.Services.Manager.RegistryService() list, err := registryService.GlobalRegistryList(session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting global registry list. %s", err) return } // copy the registry detail to remove the sensitive // password and token fields. for i, registry := range list { list[i] = registry.Copy() } c.JSON(http.StatusOK, list) } // GetGlobalRegistry // // @Summary Get a global registry by name // @Router /registries/{registry} [get] // @Produce json // @Success 200 {object} Registry // @Tags Registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param registry path string true "the registry's name" func GetGlobalRegistry(c *gin.Context) { addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryService() registry, err := registryService.GlobalRegistryFind(addr) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, registry.Copy()) } // PostGlobalRegistry // // @Summary Create a global registry // @Router /registries [post] // @Produce json // @Success 200 {object} Registry // @Tags Registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param registry body Registry true "the registry object data" func PostGlobalRegistry(c *gin.Context) { in := new(model.Registry) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing global registry. %s", err) return } registry := &model.Registry{ Address: in.Address, Username: in.Username, Password: in.Password, } if err := registry.Validate(); err != nil { c.String(http.StatusBadRequest, "Error inserting global registry. %s", err) return } registryService := server.Config.Services.Manager.RegistryService() if err := registryService.GlobalRegistryCreate(registry); err != nil { c.String(http.StatusInternalServerError, "Error inserting global registry %q. %s", in.Address, err) return } c.JSON(http.StatusOK, registry.Copy()) } // PatchGlobalRegistry // // @Summary Update a global registry by name // @Router /registries/{registry} [patch] // @Produce json // @Success 200 {object} Registry // @Tags Registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param registry path string true "the registry's name" // @Param registryData body Registry true "the registry's data" func PatchGlobalRegistry(c *gin.Context) { addr := c.Param("registry") in := new(model.Registry) err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, "Error parsing registry. %s", err) return } registryService := server.Config.Services.Manager.RegistryService() registry, err := registryService.GlobalRegistryFind(addr) if err != nil { handleDBError(c, err) return } if in.Address != "" { registry.Address = in.Address } if in.Username != "" { registry.Username = in.Username } if in.Password != "" { registry.Password = in.Password } if err := registry.Validate(); err != nil { c.String(http.StatusBadRequest, "Error updating global registry. %s", err) return } if err := registryService.GlobalRegistryUpdate(registry); err != nil { c.String(http.StatusInternalServerError, "Error updating global registry %q. %s", in.Address, err) return } c.JSON(http.StatusOK, registry.Copy()) } // DeleteGlobalRegistry // // @Summary Delete a global registry by name // @Router /registries/{registry} [delete] // @Produce plain // @Success 204 // @Tags Registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param registry path string true "the registry's name" func DeleteGlobalRegistry(c *gin.Context) { addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryService() if err := registryService.GlobalRegistryDelete(addr); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/global_secret.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) // GetGlobalSecretList // // @Summary List global secrets // @Router /secrets [get] // @Produce json // @Success 200 {array} Secret // @Tags Secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetGlobalSecretList(c *gin.Context) { secretService := server.Config.Services.Manager.SecretService() list, err := secretService.GlobalSecretList(session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting global secret list. %s", err) return } // copy the secret detail to remove the sensitive // password and token fields. for i, secret := range list { list[i] = secret.Copy() } c.JSON(http.StatusOK, list) } // GetGlobalSecret // // @Summary Get a global secret by name // @Router /secrets/{secret} [get] // @Produce json // @Success 200 {object} Secret // @Tags Secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param secret path string true "the secret's name" func GetGlobalSecret(c *gin.Context) { name := c.Param("secret") secretService := server.Config.Services.Manager.SecretService() secret, err := secretService.GlobalSecretFind(name) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, secret.Copy()) } // PostGlobalSecret // // @Summary Create a global secret // @Router /secrets [post] // @Produce json // @Success 200 {object} Secret // @Tags Secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param secret body Secret true "the secret object data" func PostGlobalSecret(c *gin.Context) { in := new(model.Secret) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing global secret. %s", err) return } secret := &model.Secret{ Name: in.Name, Value: in.Value, Events: in.Events, Images: in.Images, Note: in.Note, } if err := secret.Validate(); err != nil { c.String(http.StatusBadRequest, "Error inserting global secret. %s", err) return } secretService := server.Config.Services.Manager.SecretService() if err := secretService.GlobalSecretCreate(secret); err != nil { c.String(http.StatusInternalServerError, "Error inserting global secret %q. %s", in.Name, err) return } c.JSON(http.StatusOK, secret.Copy()) } // PatchGlobalSecret // // @Summary Update a global secret by name // @Router /secrets/{secret} [patch] // @Produce json // @Success 200 {object} Secret // @Tags Secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param secret path string true "the secret's name" // @Param secretData body SecretPatch true "the secret's data" func PatchGlobalSecret(c *gin.Context) { name := c.Param("secret") in := new(model.SecretPatch) err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, "Error parsing secret. %s", err) return } secretService := server.Config.Services.Manager.SecretService() secret, err := secretService.GlobalSecretFind(name) if err != nil { handleDBError(c, err) return } if in.Value != nil && *in.Value != "" { secret.Value = *in.Value } if in.Events != nil { secret.Events = in.Events } if in.Images != nil { secret.Images = in.Images } if in.Note != nil { secret.Note = *in.Note } if err := secret.Validate(); err != nil { c.String(http.StatusBadRequest, "Error updating global secret. %s", err) return } if err := secretService.GlobalSecretUpdate(secret); err != nil { c.String(http.StatusInternalServerError, "Error updating global secret %q. %s", in.Name, err) return } c.JSON(http.StatusOK, secret.Copy()) } // DeleteGlobalSecret // // @Summary Delete a global secret by name // @Router /secrets/{secret} [delete] // @Produce plain // @Success 204 // @Tags Secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param secret path string true "the secret's name" func DeleteGlobalSecret(c *gin.Context) { name := c.Param("secret") secretService := server.Config.Services.Manager.SecretService() if err := secretService.GlobalSecretDelete(name); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/helper.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "errors" "net/http" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func handlePipelineErr(c *gin.Context, err error) { switch { case errors.Is(err, &pipeline.ErrNotFound{}): c.String(http.StatusNotFound, "%s", err) case errors.Is(err, &pipeline.ErrBadRequest{}): c.String(http.StatusBadRequest, "%s", err) case errors.Is(err, pipeline.ErrFiltered): // for debugging purpose we add a header c.Writer.Header().Add("Pipeline-Filtered", "true") c.Status(http.StatusNoContent) default: _ = c.AbortWithError(http.StatusInternalServerError, err) } } func handleDBError(c *gin.Context, err error) { if errors.Is(err, types.ErrRecordNotExist) { c.AbortWithStatus(http.StatusNotFound) return } _ = c.AbortWithError(http.StatusInternalServerError, err) } // If the forge has a refresh token, the current access token may be stale. // Therefore, we should refresh prior to dispatching the job. func refreshUserToken(c *gin.Context, user *model.User) { _store := store.FromContext(c) _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { log.Error().Err(err).Msg("Cannot get forge from user") c.AbortWithStatus(http.StatusInternalServerError) return } forge.Refresh(c, _forge, _store, user) } // pipelineDeleteAllowed checks if the given pipeline can be deleted based on its status. // It returns a bool indicating if delete is allowed, and the pipeline's status. func pipelineDeleteAllowed(pl *model.Pipeline) bool { switch pl.Status { case model.StatusRunning, model.StatusPending, model.StatusBlocked: return false } return true } ================================================ FILE: server/api/helper_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) func TestHandlePipelineError(t *testing.T) { tests := []struct { err error code int }{ { err: pipeline.ErrFiltered, code: http.StatusNoContent, }, { err: &pipeline.ErrNotFound{Msg: "pipeline not found"}, code: http.StatusNotFound, }, { err: &pipeline.ErrBadRequest{Msg: "bad request error"}, code: http.StatusBadRequest, }, } for _, tt := range tests { r := httptest.NewRecorder() c, _ := gin.CreateTestContext(r) handlePipelineErr(c, tt.err) c.Writer.WriteHeaderNow() // require written header assert.Equal(t, tt.code, r.Code) } } ================================================ FILE: server/api/hook.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "errors" "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) // getAgentName finds an agent's name, utilizing a map as a cache. func getAgentName(store store.Store, agentNameMap map[int64]string, agentID int64) (string, bool) { // 1. Check the cache first. name, exists := agentNameMap[agentID] if exists { return name, true } // 2. If not in cache, query the store. agent, err := store.AgentFind(agentID) if err != nil || agent == nil { // Agent not found or an error occurred. return "", false } // 3. Found the agent, update the cache and return the name. if agent.Name != "" { agentNameMap[agentID] = agent.Name return agent.Name, true } return "", false } // PostHook // // @Summary Incoming webhook from forge // @Router /hook [post] // @Produce plain // @Success 200 // @Tags System // @Param hook body object true "the webhook payload; forge is automatically detected" func PostHook(c *gin.Context) { _store := store.FromContext(c) // // 1. Check if the webhook is valid and authorized // var repo *model.Repo _, err := token.ParseRequest([]token.Type{token.HookToken}, c.Request, func(t *token.Token) (string, error) { var err error repo, err = getRepoFromToken(_store, t) if err != nil { return "", err } return repo.Hash, nil }) if err != nil { msg := "failure to parse token from hook" log.Error().Err(err).Msg(msg) c.String(http.StatusBadRequest, msg) return } if repo == nil { msg := "failure to get repo from token" log.Error().Msg(msg) c.String(http.StatusBadRequest, msg) return } _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Int64("repo-id", repo.ID).Msgf("Cannot get forge with id: %d", repo.ForgeID) c.AbortWithStatus(http.StatusInternalServerError) return } // // 2. Parse the webhook data // repoFromForge, pipelineFromForge, err := _forge.Hook(c, c.Request) if err != nil { if errors.Is(err, &types.ErrIgnoreEvent{}) { msg := fmt.Sprintf("forge driver: %s", err) log.Debug().Err(err).Msg(msg) c.String(http.StatusOK, msg) return } msg := "failure to parse hook" log.Debug().Err(err).Msg(msg) c.String(http.StatusBadRequest, msg) return } if pipelineFromForge == nil { msg := "ignoring hook: hook parsing resulted in empty pipeline" log.Debug().Msg(msg) c.String(http.StatusOK, msg) return } if repoFromForge == nil { msg := "failure to ascertain repo from hook" log.Debug().Msg(msg) c.String(http.StatusBadRequest, msg) return } // // 3. Check the repo from the token is matching the repo returned by the forge // if repo.ForgeRemoteID != repoFromForge.ForgeRemoteID { log.Warn().Msgf("ignoring hook: repo %s does not match the repo from the token", repo.FullName) c.String(http.StatusBadRequest, "failure to parse token from hook") return } // // 4. Check if the repo is active and has an owner // if !repo.IsActive { log.Debug().Msgf("ignoring hook: repo %s is inactive", repoFromForge.FullName) c.Status(http.StatusNoContent) return } if repo.UserID == 0 { log.Warn().Msgf("ignoring hook. repo %s has no owner.", repo.FullName) c.Status(http.StatusNoContent) return } user, err := _store.GetUser(repo.UserID) if err != nil { handleDBError(c, err) return } forge.Refresh(c, _forge, _store, user) // // 4. Update the repo // if repo.FullName != repoFromForge.FullName { // create a redirection err = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName}) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } } repo.Update(repoFromForge) err = _store.UpdateRepo(repo) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } // // 5. Check if pull requests are allowed for this repo // if pipelineFromForge.IsPullRequest() && !repo.AllowPull { log.Debug().Str("repo", repo.FullName).Msg("ignoring hook: pull requests are disabled for this repo in woodpecker") c.Status(http.StatusNoContent) return } // // 6. Finally create a pipeline // pl, err := pipeline.Create(c, _store, repo, pipelineFromForge) if err != nil { handlePipelineErr(c, err) } else { c.JSON(http.StatusOK, pl) } } func getRepoFromToken(store store.Store, t *token.Token) (*model.Repo, error) { if t.Get("repo-forge-remote-id") != "" { forgeID, err := strconv.ParseInt(t.Get("forge-id"), 10, 64) if err != nil { return nil, err } return store.GetRepoForgeID(forgeID, model.ForgeRemoteID(t.Get("repo-forge-remote-id"))) } // get the repo by the repo-id // TODO: remove in next major repoID, err := strconv.ParseInt(t.Get("repo-id"), 10, 64) if err != nil { return nil, err } return store.GetRepo(repoID) } ================================================ FILE: server/api/hook_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api_test import ( "fmt" "net/http" "net/http/httptest" "net/url" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/api" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/model" config_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/services/permissions" registry_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks" secret_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) func TestHook(t *testing.T) { gin.SetMode(gin.TestMode) _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) _configService := config_service_mocks.NewMockService(t) _secretService := secret_service_mocks.NewMockService(t) _registryService := registry_service_mocks.NewMockService(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) user := &model.User{ ID: 123, } repo := &model.Repo{ ID: 123, ForgeRemoteID: "123", Owner: "owner", Name: "name", IsActive: true, UserID: user.ID, Hash: "secret-123-this-is-a-secret", } pipeline := &model.Pipeline{ ID: 123, RepoID: repo.ID, Event: model.EventPush, } repoToken := token.New(token.HookToken) repoToken.Set("repo-id", fmt.Sprintf("%d", repo.ID)) signedToken, err := repoToken.Sign("secret-123-this-is-a-secret") assert.NoError(t, err) header := http.Header{} header.Set("Authorization", fmt.Sprintf("Bearer %s", signedToken)) c.Request = &http.Request{ Header: header, URL: &url.URL{ Scheme: "https", }, } _manager.On("ForgeFromRepo", repo).Return(_forge, nil) _forge.On("Hook", mock.Anything, mock.Anything).Return(repo, pipeline, nil) _store.On("GetRepo", repo.ID).Return(repo, nil) _store.On("GetUser", user.ID).Return(user, nil) _store.On("UpdateRepo", repo).Return(nil) _store.On("CreatePipeline", mock.Anything).Return(nil) _manager.On("ConfigServiceFromRepo", repo).Return(_configService) _configService.On("Fetch", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) _forge.On("Netrc", mock.Anything, mock.Anything).Return(&model.Netrc{}, nil) _store.On("GetPipelineLastBefore", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) _manager.On("SecretServiceFromRepo", repo).Return(_secretService) _secretService.On("SecretListPipeline", mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) _manager.On("RegistryServiceFromRepo", repo).Return(_registryService) _registryService.On("RegistryListPipeline", mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, nil) _manager.On("EnvironmentService").Return(nil) _store.On("DeletePipeline", mock.Anything).Return(nil) api.PostHook(c) assert.Equal(t, http.StatusNoContent, c.Writer.Status()) assert.Equal(t, "true", w.Header().Get("Pipeline-Filtered")) } ================================================ FILE: server/api/login.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "encoding/base32" "errors" "fmt" "net/http" "net/url" "strconv" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "github.com/tink-crypto/tink-go/v2/subtle/random" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/shared/token" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( stateTokenDuration = time.Minute * 5 perPage = 50 maxPage = 10000 ) func HandleAuth(c *gin.Context) { // TODO: check if this is really needed c.Writer.Header().Del("Content-Type") // redirect when getting oauth error from forge to login page if err := c.Request.FormValue("error"); err != "" { query := url.Values{} query.Set("error", err) if errorDescription := c.Request.FormValue("error_description"); errorDescription != "" { query.Set("error_description", errorDescription) } if errorURI := c.Request.FormValue("error_uri"); errorURI != "" { query.Set("error_uri", errorURI) } c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/login?%s", server.Config.Server.RootPath, query.Encode())) return } _store := store.FromContext(c) code := c.Request.FormValue("code") state := c.Request.FormValue("state") isCallback := code != "" && state != "" var forgeID int64 if isCallback { // validate the state token stateToken, err := token.Parse([]token.Type{token.OAuthStateToken}, state, func(_ *token.Token) (string, error) { return server.Config.Server.JWTSecret, nil }) if err != nil { log.Error().Err(err).Msg("cannot verify state token") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state") return } _forgeID := stateToken.Get("forge-id") forgeID, err = strconv.ParseInt(_forgeID, 10, 64) if err != nil { log.Error().Err(err).Msg("forge-id of state token invalid") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state") return } } else { // only generate a state token if not a callback var err error _forgeID := c.Request.FormValue("forge_id") if _forgeID == "" { forgeID = 1 // fallback to main forge } else { forgeID, err = strconv.ParseInt(_forgeID, 10, 64) if err != nil { log.Error().Err(err).Msg("forge-id of state token invalid") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state") return } } jwtSecret := server.Config.Server.JWTSecret exp := time.Now().Add(stateTokenDuration).Unix() stateToken := token.New(token.OAuthStateToken) stateToken.Set("forge-id", strconv.FormatInt(forgeID, 10)) state, err = stateToken.SignExpires(jwtSecret, exp) if err != nil { log.Error().Err(err).Msg("cannot create state token") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } } _forge, err := server.Config.Services.Manager.ForgeByID(forgeID) if err != nil { log.Error().Err(err).Msgf("cannot get forge by id %d", forgeID) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } userFromForge, redirectURL, err := _forge.Login(c, &forge_types.OAuthRequest{ Code: c.Request.FormValue("code"), State: state, }) if err != nil { log.Error().Err(err).Msg("cannot authenticate user") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=oauth_error") return } // The user is not authorized yet -> redirect if userFromForge == nil { http.Redirect(c.Writer, c.Request, redirectURL, http.StatusSeeOther) return } // if organization filter is enabled, we need to check if the user is a member of one // of the configured organizations if server.Config.Permissions.Orgs.IsConfigured { isMember := false for page := 1; page <= maxPage; page++ { teams, terr := _forge.Teams(c, userFromForge, &model.ListOptions{ Page: page, PerPage: perPage, }) if errors.Is(terr, forge_types.ErrNotImplemented) { log.Debug().Msg("Could not fetch membership of user as forge adapter did not implement it") } else if terr != nil { log.Error().Err(terr).Msgf("cannot verify team membership for %s", userFromForge.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } if server.Config.Permissions.Orgs.IsMember(teams) { isMember = true break } } if !isMember { c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=org_access_denied") return } } var user *model.User // get the user from the database user, err = _store.GetUserByRemoteID(forgeID, userFromForge.ForgeRemoteID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { log.Error().Err(err).Msgf("cannot get user %s", userFromForge.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } // update user login (in case forge supports renaming) if user != nil { user.Login = userFromForge.Login } // re-try with login name if user == nil || errors.Is(err, types.ErrRecordNotExist) { user, err = _store.GetUserByLogin(forgeID, userFromForge.Login) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { log.Error().Err(err).Msgf("cannot get user %s", userFromForge.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } } if user == nil || errors.Is(err, types.ErrRecordNotExist) { // if self-registration is disabled we should return a not authorized error if !server.Config.Permissions.Open && !server.Config.Permissions.Admins.IsAdmin(userFromForge) { log.Error().Msgf("cannot register %s. registration closed", userFromForge.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=registration_closed") return } // create the user account user = &model.User{ ForgeID: forgeID, ForgeRemoteID: userFromForge.ForgeRemoteID, Login: userFromForge.Login, AccessToken: userFromForge.AccessToken, RefreshToken: userFromForge.RefreshToken, Expiry: userFromForge.Expiry, Email: userFromForge.Email, Avatar: userFromForge.Avatar, Hash: base32.StdEncoding.EncodeToString( random.GetRandomBytes(32), ), } // insert the user into the database if err := _store.CreateUser(user); err != nil { log.Error().Err(err).Msgf("cannot insert %s", user.Login) log.Trace().Msgf("user was: %#v", user) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } } // create or set the user's organization if it isn't linked yet if user.OrgID == 0 { // check if an org with the same name exists already and assign it to the user if it does org, err := _store.OrgFindByName(user.Login, forgeID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { log.Error().Err(err).Msgf("cannot get org for user %s", user.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } // if an org with the same name exists => assign org to the user if err == nil && org != nil { org.IsUser = true user.OrgID = org.ID if err := _store.OrgUpdate(org); err != nil { log.Error().Err(err).Msgf("cannot assign user %s to existing org %d", user.Login, org.ID) } } // if still no org with the same name exists => create a new org if user.OrgID == 0 || errors.Is(err, types.ErrRecordNotExist) { org := &model.Org{ Name: user.Login, IsUser: true, Private: false, ForgeID: user.ForgeID, } if err := _store.OrgCreate(org); err != nil { log.Error().Err(err).Msgf("cannot create org for user %s", user.Login) } user.OrgID = org.ID } } else { // update org name if necessary org, err := _store.OrgGet(user.OrgID) if err != nil { log.Error().Err(err).Msgf("cannot get org %d for user %s", user.OrgID, user.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } if org != nil && org.Name != user.Login { org.Name = user.Login if err := _store.OrgUpdate(org); err != nil { log.Error().Err(err).Msgf("cannot update org %d name to user name %s", org.ID, user.Login) } } } // update the user meta data and authorization data. user.AccessToken = userFromForge.AccessToken user.RefreshToken = userFromForge.RefreshToken user.Email = userFromForge.Email user.Avatar = userFromForge.Avatar user.ForgeID = forgeID user.ForgeRemoteID = userFromForge.ForgeRemoteID user.Login = userFromForge.Login user.Admin = user.Admin || server.Config.Permissions.Admins.IsAdmin(userFromForge) if err := _store.UpdateUser(user); err != nil { log.Error().Err(err).Msgf("cannot update user %s", user.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } exp := time.Now().Add(server.Config.Server.SessionExpires).Unix() _token := token.New(token.SessToken) _token.Set("user-id", strconv.FormatInt(user.ID, 10)) tokenString, err := _token.SignExpires(user.Hash, exp) if err != nil { log.Error().Msgf("cannot create token for user %s", user.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } err = updateRepoPermissions(c, user, _store, _forge, forgeID) if err != nil { log.Error().Err(err).Msgf("cannot update repo permissions for user %s", user.Login) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") return } httputil.SetCookie(c.Writer, c.Request, "user_sess", tokenString) c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/") } func updateRepoPermissions(c *gin.Context, user *model.User, _store store.Store, _forge forge.Forge, forgeID int64) error { repos, err := utils.Paginate(func(page int) ([]*model.Repo, error) { return _forge.Repos(c, user, &model.ListOptions{ Page: page, PerPage: perPage, }) }, maxPage) if err != nil { return err } var repoIDs []int64 for _, forgeRepo := range repos { // make sure forgeID is set forgeRepo.ForgeID = forgeID dbRepo, err := _store.GetRepoForgeID(forgeID, forgeRepo.ForgeRemoteID) if err != nil && errors.Is(err, types.ErrRecordNotExist) { continue } if err != nil { return err } if !dbRepo.IsActive { continue } log.Debug().Msgf("synced user permission for user %s and repo %s", user.Login, dbRepo.FullName) perm := forgeRepo.Perm perm.RepoID = dbRepo.ID perm.UserID = user.ID perm.Synced = time.Now().Unix() if err := _store.PermUpsert(perm); err != nil { return err } repoIDs = append(repoIDs, dbRepo.ID) } if err := _store.PermPrune(user.ID, repoIDs); err != nil { return err } return nil } func GetLogout(c *gin.Context) { httputil.DelCookie(c.Writer, c.Request, "user_sess") httputil.DelCookie(c.Writer, c.Request, "user_last") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/") } ================================================ FILE: server/api/login_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api_test import ( "context" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/api" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/services/permissions" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) func TestHandleAuth(t *testing.T) { gin.SetMode(gin.TestMode) user := &model.User{ ID: 1, OrgID: 1, ForgeID: 1, ForgeRemoteID: "remote-id-1", Login: "test", Email: "test@example.com", Admin: false, } org := &model.Org{ ID: 1, Name: user.Login, } server.Config.Server.SessionExpires = time.Hour t.Run("should handle errors from the callback", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) query := url.Values{} query.Set("error", "invalid_scope") query.Set("error_description", "The requested scope is invalid, unknown, or malformed") query.Set("error_uri", "https://developer.atlassian.com/cloud/jira/platform/rest/#api-group-OAuth2-ErrorHandling") c.Request = &http.Request{ Header: make(http.Header), Method: http.MethodGet, URL: &url.URL{ Scheme: "https", Path: "/authorize", RawQuery: query.Encode(), }, } api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, fmt.Sprintf("/login?%s", query.Encode()), c.Writer.Header().Get("Location")) }) t.Run("should fail if the state is wrong", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) query := url.Values{} query.Set("code", "assumed_to_be_valid_code") wrongToken := token.New(token.OAuthStateToken) wrongToken.Set("forge_id", "1") signedWrongToken, _ := wrongToken.Sign("wrong_secret") query.Set("state", signedWrongToken) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", RawQuery: query.Encode(), }, } api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/login?error=invalid_state", c.Writer.Header().Get("Location")) }) t.Run("should redirect to forge login page", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } _manager.On("ForgeByID", int64(1)).Return(_forge, nil) forgeRedirectURL := "" _forge.On("Login", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { state, ok := args.Get(1).(*forge_types.OAuthRequest) if ok { forgeRedirectURL = fmt.Sprintf("https://my-awesome-forge.com/oauth/authorize?client_id=client-id&state=%s", state.State) } }).Return(nil, func(context.Context, *forge_types.OAuthRequest) string { return forgeRedirectURL }, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, forgeRedirectURL, c.Writer.Header().Get("Location")) }) t.Run("should register a new user", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _store.On("GetUserByRemoteID", user.ForgeID, user.ForgeRemoteID).Return(nil, types.ErrRecordNotExist) _store.On("GetUserByLogin", user.ForgeID, user.Login).Return(nil, types.ErrRecordNotExist) _store.On("CreateUser", mock.Anything).Return(nil) _store.On("OrgFindByName", user.Login, user.ForgeID).Return(nil, nil) _store.On("OrgCreate", mock.Anything).Return(nil) _store.On("UpdateUser", mock.Anything).Return(nil) _store.On("PermPrune", mock.Anything, []int64(nil)).Return(nil) _forge.On("Repos", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/", c.Writer.Header().Get("Location")) assert.NotEmpty(t, c.Writer.Header().Get("Set-Cookie")) }) t.Run("should login an existing user", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _store.On("GetUserByRemoteID", user.ForgeID, user.ForgeRemoteID).Return(user, nil) _store.On("OrgGet", org.ID).Return(org, nil) _store.On("UpdateUser", mock.Anything).Return(nil) _store.On("PermPrune", mock.Anything, []int64(nil)).Return(nil) _forge.On("Repos", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/", c.Writer.Header().Get("Location")) assert.NotEmpty(t, c.Writer.Header().Get("Set-Cookie")) }) t.Run("should deny a new user if registration is closed", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = false server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _store.On("GetUserByRemoteID", user.ForgeID, user.ForgeRemoteID).Return(nil, types.ErrRecordNotExist) _store.On("GetUserByLogin", user.ForgeID, user.Login).Return(nil, types.ErrRecordNotExist) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/login?error=registration_closed", c.Writer.Header().Get("Location")) }) t.Run("should deny a user with missing org access", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs([]string{"org1"}) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _forge.On("Teams", mock.Anything, user, mock.Anything).Return([]*model.Team{ { Login: "org2", }, }, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/login?error=org_access_denied", c.Writer.Header().Get("Location")) }) t.Run("should create an user org if it does not exists", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } user.OrgID = 0 _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _store.On("GetUserByRemoteID", user.ForgeID, user.ForgeRemoteID).Return(user, nil) _store.On("OrgFindByName", user.Login, user.ForgeID).Return(nil, types.ErrRecordNotExist) _store.On("OrgCreate", mock.Anything).Return(nil) _store.On("UpdateUser", mock.Anything).Return(nil) _store.On("PermPrune", mock.Anything, []int64(nil)).Return(nil) _forge.On("Repos", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/", c.Writer.Header().Get("Location")) assert.NotEmpty(t, c.Writer.Header().Get("Set-Cookie")) }) t.Run("should link an user org if it has the same name as the user", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } user.OrgID = 0 _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _store.On("GetUserByRemoteID", user.ForgeID, user.ForgeRemoteID).Return(user, nil) _store.On("OrgFindByName", user.Login, user.ForgeID).Return(org, nil) _store.On("OrgUpdate", mock.Anything).Return(nil) _store.On("UpdateUser", mock.Anything).Return(nil) _store.On("PermPrune", mock.Anything, []int64(nil)).Return(nil) _forge.On("Repos", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/", c.Writer.Header().Get("Location")) assert.NotEmpty(t, c.Writer.Header().Get("Set-Cookie")) }) t.Run("should update an user org if the user name was changed", func(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) _store := store_mocks.NewMockStore(t) server.Config.Services.Manager = _manager server.Config.Permissions.Open = true server.Config.Permissions.Orgs = permissions.NewOrgs(nil) server.Config.Permissions.Admins = permissions.NewAdmins(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", _store) c.Request = &http.Request{ Header: make(http.Header), URL: &url.URL{ Scheme: "https", }, } org.Name = "not-the-user-name" _manager.On("ForgeByID", int64(1)).Return(_forge, nil) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil) _store.On("GetUserByRemoteID", user.ForgeID, user.ForgeRemoteID).Return(user, nil) _store.On("OrgGet", user.OrgID).Return(org, nil) _store.On("OrgUpdate", mock.Anything).Return(nil) _store.On("UpdateUser", mock.Anything).Return(nil) _store.On("PermPrune", mock.Anything, []int64(nil)).Return(nil) _forge.On("Repos", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) api.HandleAuth(c) assert.Equal(t, http.StatusSeeOther, c.Writer.Status()) assert.Equal(t, "/", c.Writer.Header().Get("Location")) assert.NotEmpty(t, c.Writer.Header().Get("Set-Cookie")) }) } ================================================ FILE: server/api/metrics/prometheus.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metrics import ( "errors" "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "go.woodpecker-ci.org/woodpecker/v3/server" ) // errInvalidToken is returned when the api request token is invalid. var errInvalidToken = errors.New("invalid or missing token") // PromHandler will pass the call from /api/metrics/prometheus to prometheus. func PromHandler() gin.HandlerFunc { handler := promhttp.Handler() return func(c *gin.Context) { token := server.Config.Prometheus.AuthToken if token == "" { c.AbortWithStatus(http.StatusNotFound) return } header := c.Request.Header.Get("Authorization") if header == "" { c.String(http.StatusUnauthorized, errInvalidToken.Error()) return } bearer := fmt.Sprintf("Bearer %s", token) if header != bearer { c.String(http.StatusForbidden, errInvalidToken.Error()) return } handler.ServeHTTP(c.Writer, c.Request) } } ================================================ FILE: server/api/org.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // GetOrgs // // @Summary List organizations // @Description Returns all registered orgs in the system. Requires admin rights. // @Router /orgs [get] // @Produce json // @Success 200 {array} Org // @Tags Orgs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetOrgs(c *gin.Context) { orgs, err := store.FromContext(c).OrgList(session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting user list. %s", err) return } c.JSON(http.StatusOK, orgs) } // GetOrg // // @Summary Get an organization // @Router /orgs/{org_id} [get] // @Produce json // @Success 200 {array} Org // @Tags Organization // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the organization's id" func GetOrg(c *gin.Context) { org := session.Org(c) c.JSON(http.StatusOK, org) } // GetOrgPermissions // // @Summary Get the permissions of the currently authenticated user for the given organization // @Router /orgs/{org_id}/permissions [get] // @Produce json // @Success 200 {array} OrgPerm // @Tags Organization permissions // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the organization's id" func GetOrgPermissions(c *gin.Context) { user := session.User(c) org := session.Org(c) _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { log.Error().Err(err).Msg("Cannot get forge from user") c.AbortWithStatus(http.StatusInternalServerError) return } if user == nil { c.JSON(http.StatusOK, &model.OrgPerm{}) return } if (org.IsUser && org.Name == user.Login) || (user.Admin && !org.IsUser) { c.JSON(http.StatusOK, &model.OrgPerm{ Member: true, Admin: true, }) return } else if org.IsUser { c.JSON(http.StatusOK, &model.OrgPerm{}) return } perm, err := server.Config.Services.Membership.Get(c, _forge, user, org.Name) if err != nil { c.String(http.StatusInternalServerError, "Error getting membership for %d. %s", org.ID, err) return } c.JSON(http.StatusOK, perm) } // LookupOrg // // @Summary Lookup an organization by full name // @Router /orgs/lookup/{org_full_name} [get] // @Produce json // @Success 200 {object} Org // @Tags Orgs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_full_name path string true "the organizations full name / slug" func LookupOrg(c *gin.Context) { _store := store.FromContext(c) user := session.User(c) _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { log.Error().Err(err).Msg("Cannot get forge from user") c.AbortWithStatus(http.StatusInternalServerError) return } orgFullName := strings.TrimLeft(c.Param("org_full_name"), "/") org, err := _store.OrgFindByName(orgFullName, user.ForgeID) if err != nil { handleDBError(c, err) return } // don't leak private org infos if org.Private { user := session.User(c) if user == nil { c.AbortWithStatus(http.StatusNotFound) return } if !user.Admin && org.Name != user.Login { c.AbortWithStatus(http.StatusNotFound) return } else if !user.Admin { perm, err := server.Config.Services.Membership.Get(c, _forge, user, org.Name) if err != nil { log.Error().Err(err).Msg("failed to check membership") c.Status(http.StatusInternalServerError) return } if perm == nil || !perm.Member { c.AbortWithStatus(http.StatusNotFound) return } } } c.JSON(http.StatusOK, org) } // DeleteOrg // // @Summary Delete an organization // @Description Deletes the given org. Requires admin rights. // @Router /orgs/{id} [delete] // @Produce plain // @Success 204 // @Tags Orgs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param id path string true "the org's id" func DeleteOrg(c *gin.Context) { _store := store.FromContext(c) org := session.Org(c) if err := _store.OrgDelete(org.ID); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/org_registry.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) // GetOrgRegistry // // @Summary Get a organization registry by address // @Router /orgs/{org_id}/registries/{registry} [get] // @Produce json // @Success 200 {object} Registry // @Tags Organization registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param registry path string true "the registry's address" func GetOrgRegistry(c *gin.Context) { org := session.Org(c) addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryService() registry, err := registryService.OrgRegistryFind(org.ID, addr) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, registry.Copy()) } // GetOrgRegistryList // // @Summary List organization registries // @Router /orgs/{org_id}/registries [get] // @Produce json // @Success 200 {array} Registry // @Tags Organization registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetOrgRegistryList(c *gin.Context) { org := session.Org(c) registryService := server.Config.Services.Manager.RegistryService() list, err := registryService.OrgRegistryList(org.ID, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting registry list for %q. %s", org.ID, err) return } // copy the registry detail to remove the sensitive // password and token fields. for i, registry := range list { list[i] = registry.Copy() } c.JSON(http.StatusOK, list) } // PostOrgRegistry // // @Summary Create an organization registry // @Router /orgs/{org_id}/registries [post] // @Produce json // @Success 200 {object} Registry // @Tags Organization registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param registryData body Registry true "the new registry" func PostOrgRegistry(c *gin.Context) { org := session.Org(c) in := new(model.Registry) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing org %q registry. %s", org.ID, err) return } registry := &model.Registry{ OrgID: org.ID, Address: in.Address, Username: in.Username, Password: in.Password, } if err := registry.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error inserting org %q registry. %s", org.ID, err) return } registryService := server.Config.Services.Manager.RegistryService() if err := registryService.OrgRegistryCreate(org.ID, registry); err != nil { c.String(http.StatusInternalServerError, "Error inserting org %q registry %q. %s", org.ID, in.Address, err) return } c.JSON(http.StatusOK, registry.Copy()) } // PatchOrgRegistry // // @Summary Update an organization registry by name // @Router /orgs/{org_id}/registries/{registry} [patch] // @Produce json // @Success 200 {object} Registry // @Tags Organization registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param registry path string true "the registry's name" // @Param registryData body Registry true "the update registry data" func PatchOrgRegistry(c *gin.Context) { org := session.Org(c) addr := c.Param("registry") in := new(model.Registry) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing registry. %s", err) return } registryService := server.Config.Services.Manager.RegistryService() registry, err := registryService.OrgRegistryFind(org.ID, addr) if err != nil { handleDBError(c, err) return } if in.Address != "" { registry.Address = in.Address } if in.Username != "" { registry.Username = in.Username } if in.Password != "" { registry.Password = in.Password } if err := registry.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error updating org %q registry. %s", org.ID, err) return } if err := registryService.OrgRegistryUpdate(org.ID, registry); err != nil { c.String(http.StatusInternalServerError, "Error updating org %q registry %q. %s", org.ID, in.Address, err) return } c.JSON(http.StatusOK, registry.Copy()) } // DeleteOrgRegistry // // @Summary Delete an organization registry by name // @Router /orgs/{org_id}/registries/{registry} [delete] // @Produce plain // @Success 204 // @Tags Organization registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param registry path string true "the registry's name" func DeleteOrgRegistry(c *gin.Context) { org := session.Org(c) addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryService() if err := registryService.OrgRegistryDelete(org.ID, addr); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/org_secret.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) // GetOrgSecret // // @Summary Get a organization secret by name // @Router /orgs/{org_id}/secrets/{secret} [get] // @Produce json // @Success 200 {object} Secret // @Tags Organization secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param secret path string true "the secret's name" func GetOrgSecret(c *gin.Context) { org := session.Org(c) name := c.Param("secret") secretService := server.Config.Services.Manager.SecretService() secret, err := secretService.OrgSecretFind(org.ID, name) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, secret.Copy()) } // GetOrgSecretList // // @Summary List organization secrets // @Router /orgs/{org_id}/secrets [get] // @Produce json // @Success 200 {array} Secret // @Tags Organization secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetOrgSecretList(c *gin.Context) { org := session.Org(c) secretService := server.Config.Services.Manager.SecretService() list, err := secretService.OrgSecretList(org.ID, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting secret list for %q. %s", org.ID, err) return } // copy the secret detail to remove the sensitive // password and token fields. for i, secret := range list { list[i] = secret.Copy() } c.JSON(http.StatusOK, list) } // PostOrgSecret // // @Summary Create an organization secret // @Router /orgs/{org_id}/secrets [post] // @Produce json // @Success 200 {object} Secret // @Tags Organization secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param secretData body Secret true "the new secret" func PostOrgSecret(c *gin.Context) { org := session.Org(c) in := new(model.Secret) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing org %q secret. %s", org.ID, err) return } secret := &model.Secret{ OrgID: org.ID, Name: in.Name, Value: in.Value, Events: in.Events, Images: in.Images, Note: in.Note, } if err := secret.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error inserting org %q secret. %s", org.ID, err) return } secretService := server.Config.Services.Manager.SecretService() if err := secretService.OrgSecretCreate(org.ID, secret); err != nil { c.String(http.StatusInternalServerError, "Error inserting org %q secret %q. %s", org.ID, in.Name, err) return } c.JSON(http.StatusOK, secret.Copy()) } // PatchOrgSecret // // @Summary Update an organization secret by name // @Router /orgs/{org_id}/secrets/{secret} [patch] // @Produce json // @Success 200 {object} Secret // @Tags Organization secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param secret path string true "the secret's name" // @Param secretData body SecretPatch true "the update secret data" func PatchOrgSecret(c *gin.Context) { org := session.Org(c) name := c.Param("secret") in := new(model.SecretPatch) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing secret. %s", err) return } secretService := server.Config.Services.Manager.SecretService() secret, err := secretService.OrgSecretFind(org.ID, name) if err != nil { handleDBError(c, err) return } if in.Value != nil && *in.Value != "" { secret.Value = *in.Value } if in.Events != nil { secret.Events = in.Events } if in.Images != nil { secret.Images = in.Images } if in.Note != nil { secret.Note = *in.Note } if err := secret.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error updating org %q secret. %s", org.ID, err) return } if err := secretService.OrgSecretUpdate(org.ID, secret); err != nil { c.String(http.StatusInternalServerError, "Error updating org %q secret %q. %s", org.ID, in.Name, err) return } c.JSON(http.StatusOK, secret.Copy()) } // DeleteOrgSecret // // @Summary Delete an organization secret by name // @Router /orgs/{org_id}/secrets/{secret} [delete] // @Produce plain // @Success 204 // @Tags Organization secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param org_id path string true "the org's id" // @Param secret path string true "the secret's name" func DeleteOrgSecret(c *gin.Context) { org := session.Org(c) name := c.Param("secret") secretService := server.Config.Services.Manager.SecretService() if err := secretService.OrgSecretDelete(org.ID, name); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/pipeline.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) // CreatePipeline // // @Summary Trigger a manual pipeline // @Router /repos/{repo_id}/pipelines [post] // @Produce json // @Success 200 {object} Pipeline // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param options body PipelineOptions true "the options for the pipeline to run" func CreatePipeline(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") c.AbortWithStatus(http.StatusInternalServerError) return } // parse create options var opts model.PipelineOptions err = json.NewDecoder(c.Request.Body).Decode(&opts) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } user := session.User(c) lastCommit, err := _forge.BranchHead(c, user, repo, opts.Branch) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("could not fetch branch head: %w", err)) return } tmpPipeline := createTmpPipeline(model.EventManual, lastCommit, user, &opts) pl, err := pipeline.Create(c, _store, repo, tmpPipeline) if err != nil { handlePipelineErr(c, err) return } if pl != nil { c.JSON(http.StatusOK, pl.ToAPIModel()) } else { c.Status(http.StatusNoContent) } } func createTmpPipeline(event model.WebhookEvent, commit *model.Commit, user *model.User, opts *model.PipelineOptions) *model.Pipeline { return &model.Pipeline{ Event: event, Commit: commit.SHA, Branch: opts.Branch, Timestamp: time.Now().UTC().Unix(), Avatar: user.Avatar, Message: "MANUAL PIPELINE @ " + opts.Branch, Ref: opts.Branch, AdditionalVariables: opts.Variables, Author: user.Login, Email: user.Email, ForgeURL: commit.ForgeURL, } } // GetPipelines // // @Summary List repository pipelines // @Description Get a list of pipelines for a repository. // @Router /repos/{repo_id}/pipelines [get] // @Produce json // @Success 200 {array} Pipeline // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) // @Param before query string false "only return pipelines before this RFC3339 date" // @Param after query string false "only return pipelines after this RFC3339 date" // @Param branch query string false "filter pipelines by branch" // @Param event query string false "filter pipelines by webhook events (comma separated)" // @Param ref query string false "filter pipelines by strings contained in ref" // @Param status query string false "filter pipelines by status" func GetPipelines(c *gin.Context) { repo := session.Repo(c) filter := &model.PipelineFilter{ Branch: c.Query("branch"), RefContains: c.Query("ref"), } if events := c.Query("event"); events != "" { eventList := strings.Split(events, ",") wel := make(model.WebhookEventList, 0, len(eventList)) for _, event := range eventList { we := model.WebhookEvent(event) if err := we.Validate(); err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } wel = append(wel, we) } filter.Events = wel } if status := c.Query("status"); status != "" { ps := model.StatusValue(status) if err := ps.Validate(); err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } filter.Status = ps } if before := c.Query("before"); before != "" { beforeDt, err := time.Parse(time.RFC3339, before) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } filter.Before = beforeDt.Unix() } if after := c.Query("after"); after != "" { afterDt, err := time.Parse(time.RFC3339, after) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } filter.After = afterDt.Unix() } pipelines, err := store.FromContext(c).GetPipelineList(repo, session.Pagination(c), filter) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } pls := make([]*model.APIPipeline, len(pipelines)) for i, p := range pipelines { pls[i] = p.ToAPIModel() } c.JSON(http.StatusOK, pls) } // DeletePipeline // // @Summary Delete a pipeline // @Router /repos/{repo_id}/pipelines/{pipeline_number} [delete] // @Produce plain // @Success 204 // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func DeletePipeline(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } if ok := pipelineDeleteAllowed(pl); !ok { c.String(http.StatusUnprocessableEntity, "Cannot delete pipeline with status %s", pl.Status) return } err = store.FromContext(c).DeletePipeline(pl) if err != nil { c.String(http.StatusInternalServerError, "Error deleting pipeline. %s", err) return } c.Status(http.StatusNoContent) } // GetPipeline // // @Summary Get a repositories pipeline // @Router /repos/{repo_id}/pipelines/{pipeline_number} [get] // @Produce json // @Success 200 {object} Pipeline // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline, OR 'latest'" func GetPipeline(c *gin.Context) { _store := store.FromContext(c) if c.Param("pipeline_number") == "latest" { GetPipelineLastByBranch(c) return } repo := session.Repo(c) num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } if pl.Workflows, err = _store.WorkflowGetTree(pl); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, pl.ToAPIModel()) } func GetPipelineLastByBranch(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) branch := c.DefaultQuery("branch", repo.Branch) pl, err := _store.GetPipelineLastByBranch(repo, branch) if err != nil { handleDBError(c, err) return } if pl.Workflows, err = _store.WorkflowGetTree(pl); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, pl.ToAPIModel()) } // GetStepLogs // // @Summary Get logs for a pipeline step // @Router /repos/{repo_id}/logs/{pipeline_number}/{step_id} [get] // @Produce json // @Success 200 {array} LogEntry // @Tags Pipeline logs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" // @Param step_id path int true "the step id" func GetStepLogs(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) // parse the pipeline number and step sequence number from // the request parameter. num, err := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } stepID, err := strconv.ParseInt(c.Params.ByName("step_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } step, err := _store.StepLoad(pl.ID, stepID) if err != nil { handleDBError(c, err) return } logs, err := server.Config.Services.LogStore.LogFind(step) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, logs) } // DeleteStepLogs // // @Summary Delete step logs of a pipeline // @Router /repos/{repo_id}/logs/{pipeline_number}/{step_id} [delete] // @Produce plain // @Success 204 // @Tags Pipeline logs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" // @Param step_id path int true "the step id" func DeleteStepLogs(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) pipelineNumber, err := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } _pipeline, err := _store.GetPipelineNumber(repo, pipelineNumber) if err != nil { handleDBError(c, err) return } stepID, err := strconv.ParseInt(c.Params.ByName("step_id"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } _step, err := _store.StepLoad(_pipeline.ID, stepID) if err != nil { handleDBError(c, err) return } switch _step.State { case model.StatusRunning, model.StatusPending: c.String(http.StatusUnprocessableEntity, "Cannot delete logs for a pending or running step") return } err = _store.LogDelete(_step) if err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } // GetPipelineConfig // // @Summary Get configuration files for a pipeline // @Router /repos/{repo_id}/pipelines/{pipeline_number}/config [get] // @Produce json // @Success 200 {array} Config // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func GetPipelineConfig(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } configs, err := _store.ConfigsForPipeline(pl.ID) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, configs) } // GetPipelineMetadata // // @Summary Get metadata for a pipeline or a specific workflow, including previous pipeline info // @Router /repos/{repo_id}/pipelines/{pipeline_number}/metadata [get] // @Produce json // @Success 200 {object} metadata.Metadata // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func GetPipelineMetadata(c *gin.Context) { repo := session.Repo(c) num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } _store := store.FromContext(c) currentPipeline, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { c.AbortWithStatus(http.StatusInternalServerError) return } prevPipeline, err := _store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { handleDBError(c, err) return } metadata := step_builder.MetadataFromStruct(forge, repo, currentPipeline, prevPipeline, nil, server.Config.Server.Host) c.JSON(http.StatusOK, metadata) } // CancelPipeline // // @Summary Cancel a pipeline // @Router /repos/{repo_id}/pipelines/{pipeline_number}/cancel [post] // @Produce plain // @Success 200 // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func CancelPipeline(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) user := session.User(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") c.AbortWithStatus(http.StatusInternalServerError) return } num, _ := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } if err := pipeline.Cancel(c, _forge, _store, repo, user, pl, &model.CancelInfo{ CanceledByUser: user.Login, }); err != nil { handlePipelineErr(c, err) } else { c.Status(http.StatusNoContent) } } // PostApproval // // @Summary Approve and start a pipeline // @Router /repos/{repo_id}/pipelines/{pipeline_number}/approve [post] // @Produce json // @Success 200 {object} Pipeline // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func PostApproval(c *gin.Context) { var ( _store = store.FromContext(c) repo = session.Repo(c) user = session.User(c) num, _ = strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) ) pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } newPipeline, err := pipeline.Approve(c, _store, pl, user, repo) if err != nil { handlePipelineErr(c, err) } else { c.JSON(http.StatusOK, newPipeline.ToAPIModel()) } } // PostDecline // // @Summary Decline a pipeline // @Router /repos/{repo_id}/pipelines/{pipeline_number}/decline [post] // @Produce json // @Success 200 {object} Pipeline // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func PostDecline(c *gin.Context) { var ( _store = store.FromContext(c) repo = session.Repo(c) user = session.User(c) num, _ = strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) ) pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } pl, err = pipeline.Decline(c, _store, pl, user, repo) if err != nil { handlePipelineErr(c, err) } else { c.JSON(http.StatusOK, pl.ToAPIModel()) } } // GetPipelineQueue // // @Summary List pipelines in queue // @Router /pipelines [get] // @Produce json // @Success 200 {array} Feed // @Tags Pipeline queues // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func GetPipelineQueue(c *gin.Context) { out, err := store.FromContext(c).GetPipelineQueue() if err != nil { c.String(http.StatusInternalServerError, "Error getting pipeline queue. %s", err) return } c.JSON(http.StatusOK, out) } // PostPipeline // // @Summary Restart a pipeline // @Description Restarts a pipeline optional with altered event, deploy or environment // @Router /repos/{repo_id}/pipelines/{pipeline_number} [post] // @Produce json // @Success 200 {object} Pipeline // @Tags Pipelines // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" // @Param event query string false "override the event type" // @Param deploy_to query string false "override the target deploy value" func PostPipeline(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } user, err := _store.GetUser(repo.UserID) if err != nil { handleDBError(c, err) return } pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } // refresh the token to make sure, pipeline.Restart can still obtain the pipeline config if necessary again refreshUserToken(c, user) // make Deploy overridable // make Deploy task overridable pl.DeployTask = c.DefaultQuery("deploy_task", pl.DeployTask) // make Event overridable to deploy // TODO: refactor to use own proper API for deploy if event, ok := c.GetQuery("event"); ok { pl.Event = model.WebhookEvent(event) if pl.Event != model.EventDeploy { _ = c.AbortWithError(http.StatusBadRequest, model.ErrInvalidWebhookEvent) return } if !repo.AllowDeploy { _ = c.AbortWithError(http.StatusForbidden, fmt.Errorf("repo does not allow deployments")) return } pl.DeployTo = c.DefaultQuery("deploy_to", pl.DeployTo) } // Read query string parameters into pipelineParams, exclude reserved params envs := map[string]string{} for key, val := range c.Request.URL.Query() { switch key { // Skip some options of the endpoint case "fork", "event", "deploy_to": continue default: // We only accept string literals, because pipeline parameters will be // injected as environment variables // TODO: sanitize the value envs[key] = val[0] } } newPipeline, err := pipeline.Restart(c, _store, pl, user, repo, envs) if err != nil { handlePipelineErr(c, err) } else { c.JSON(http.StatusOK, newPipeline.ToAPIModel()) } } // DeletePipelineLogs // // @Summary Deletes all logs of a pipeline // @Router /repos/{repo_id}/logs/{pipeline_number} [delete] // @Produce plain // @Success 204 // @Tags Pipeline logs // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param pipeline_number path int true "the number of the pipeline" func DeletePipelineLogs(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) num, _ := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64) pl, err := _store.GetPipelineNumber(repo, num) if err != nil { handleDBError(c, err) return } steps, err := _store.StepList(pl.ID) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } if ok := pipelineDeleteAllowed(pl); !ok { c.String(http.StatusUnprocessableEntity, "Cannot delete logs for pipeline with status %s", pl.Status) return } for _, step := range steps { if lErr := server.Config.Services.LogStore.LogDelete(step); err != nil { err = errors.Join(err, lErr) } } if err != nil { c.String(http.StatusInternalServerError, "Error deleting pipeline logs. %s", err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/pipeline_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/server" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" queue_mocks "go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" config_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" registry_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks" secret_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) var fakePipeline = &model.Pipeline{ ID: 2, Number: 2, Status: model.StatusSuccess, } func TestGetPipelines(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should get pipelines", func(t *testing.T) { pipelines := []*model.Pipeline{fakePipeline} mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) GetPipelines(c) mockStore.AssertCalled(t, "GetPipelineList", mock.Anything, mock.Anything, mock.Anything) assert.Equal(t, http.StatusOK, c.Writer.Status()) }) t.Run("should not parse pipeline filter", func(t *testing.T) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16&after=2023-01-15", nil) GetPipelines(c) assert.Equal(t, http.StatusBadRequest, c.Writer.Status()) }) t.Run("should parse pipeline filter", func(t *testing.T) { pipelines := []*model.Pipeline{fakePipeline} mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("store", mockStore) c.Request, _ = http.NewRequest(http.MethodDelete, "/?2023-01-16T15:00:00Z&after=2023-01-15T15:00:00Z", nil) GetPipelines(c) assert.Equal(t, http.StatusOK, c.Writer.Status()) }) t.Run("should parse pipeline filter with tz offset", func(t *testing.T) { pipelines := []*model.Pipeline{fakePipeline} mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("store", mockStore) c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16T15:00:00%2B01:00&after=2023-01-15T15:00:00%2B01:00", nil) GetPipelines(c) assert.Equal(t, http.StatusOK, c.Writer.Status()) }) t.Run("should filter pipelines by events", func(t *testing.T) { pipelines := []*model.Pipeline{fakePipeline} mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Request, _ = http.NewRequest(http.MethodGet, "/?event=push,pull_request", nil) GetPipelines(c) mockStore.AssertCalled(t, "GetPipelineList", mock.Anything, mock.Anything, &model.PipelineFilter{ Events: model.WebhookEventList{model.EventPush, model.EventPull}, }) assert.Equal(t, http.StatusOK, c.Writer.Status()) }) } func TestDeletePipeline(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should delete pipeline", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil) mockStore.On("DeletePipeline", mock.Anything).Return(nil) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("store", mockStore) c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} DeletePipeline(c) mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) mockStore.AssertCalled(t, "DeletePipeline", mock.Anything) assert.Equal(t, http.StatusNoContent, c.Writer.Status()) }) t.Run("should not delete without pipeline number", func(t *testing.T) { c, _ := gin.CreateTestContext(httptest.NewRecorder()) DeletePipeline(c) assert.Equal(t, http.StatusBadRequest, c.Writer.Status()) }) t.Run("should not delete pending", func(t *testing.T) { fakePipeline := *fakePipeline fakePipeline.Status = model.StatusPending mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(&fakePipeline, nil) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Set("store", mockStore) c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} DeletePipeline(c) mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything) assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status()) }) } func TestGetPipelineMetadata(t *testing.T) { gin.SetMode(gin.TestMode) prevPipeline := &model.Pipeline{ ID: 1, Number: 1, Status: model.StatusFailure, } fakeRepo := &model.Repo{ID: 1} mockForge := forge_mocks.NewMockForge(t) mockForge.On("Name").Return("mock") mockForge.On("URL").Return("https://codeberg.org") mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) server.Config.Services.Manager = mockManager mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineNumber", mock.Anything, int64(2)).Return(fakePipeline, nil) mockStore.On("GetPipelineLastBefore", mock.Anything, mock.Anything, int64(2)).Return(prevPipeline, nil) t.Run("PipelineMetadata", func(t *testing.T) { t.Run("should get pipeline metadata", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} c.Set("store", mockStore) c.Set("forge", mockForge) c.Set("repo", fakeRepo) GetPipelineMetadata(c) assert.Equal(t, http.StatusOK, w.Code) var response metadata.Metadata err := json.Unmarshal(w.Body.Bytes(), &response) assert.NoError(t, err) assert.Equal(t, int64(1), response.Repo.ID) assert.Equal(t, int64(2), response.Curr.Number) assert.Equal(t, int64(1), response.Prev.Number) }) t.Run("should return bad request for invalid pipeline number", func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "pipeline_number", Value: "invalid"}} GetPipelineMetadata(c) assert.Equal(t, http.StatusBadRequest, w.Code) }) t.Run("should return not found for non-existent pipeline", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineNumber", mock.Anything, int64(3)).Return((*model.Pipeline)(nil), types.ErrRecordNotExist) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Params = gin.Params{{Key: "pipeline_number", Value: "3"}} c.Set("store", mockStore) c.Set("repo", fakeRepo) GetPipelineMetadata(c) assert.Equal(t, http.StatusNotFound, w.Code) }) }) } func TestCancelPipeline(t *testing.T) { gin.SetMode(gin.TestMode) t.Run("should cancel running pipeline", func(t *testing.T) { runningPipeline := &model.Pipeline{ ID: 2, Number: 2, Status: model.StatusRunning, } fakeRepo := &model.Repo{ID: 1} fakeUser := &model.User{Login: "testuser"} mockForge := forge_mocks.NewMockForge(t) mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineNumber", fakeRepo, int64(2)).Return(runningPipeline, nil) mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{}, nil) mockStore.On("UpdatePipeline", mock.Anything).Return(nil) mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) server.Config.Services.Manager = mockManager server.Config.Services.Scheduler = scheduler.NewScheduler(nil, memory.New()) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Set("repo", fakeRepo) c.Set("user", fakeUser) c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}} CancelPipeline(c) assert.Equal(t, http.StatusNoContent, c.Writer.Status()) }) } func TestCreatePipeline(t *testing.T) { gin.SetMode(gin.TestMode) // 1. normal: config fetch succeeds (no error, returns config) -> success t.Run("normal workflow - config can be read", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockConfigService := config_service_mocks.NewMockService(t) mockSecretService := secret_service_mocks.NewMockService(t) mockRegistryService := registry_service_mocks.NewMockService(t) fakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: "test/repo"} fakeUser := &model.User{ID: 1, Login: "testuser", Email: "test@example.com", Avatar: "avatar.png", Hash: "hash123"} fakeCommit := &model.Commit{SHA: "abc123", ForgeURL: "https://example.com/commit/abc123"} mockForge := forge_mocks.NewMockForge(t) mockForge.On("Name").Return("mock").Maybe() mockForge.On("URL").Return("https://example.com").Maybe() mockForge.On("BranchHead", mock.Anything, fakeUser, fakeRepo, "main").Return(fakeCommit, nil) mockForge.On("Netrc", fakeUser, fakeRepo).Return(&model.Netrc{ Machine: "example.com", Login: "testuser", Password: "testpass", }, nil).Maybe() mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe() mockSecretService.On("SecretListPipeline", mock.Anything, fakeRepo, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{}, nil).Maybe() mockRegistryService.On("RegistryListPipeline", mock.Anything, fakeRepo, mock.Anything, mock.Anything).Return([]*model.Registry{}, nil).Maybe() mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) mockManager.On("ConfigServiceFromRepo", fakeRepo).Return(mockConfigService) mockManager.On("SecretServiceFromRepo", fakeRepo).Return(mockSecretService).Maybe() mockManager.On("RegistryServiceFromRepo", fakeRepo).Return(mockRegistryService).Maybe() mockManager.On("EnvironmentService").Return(nil).Maybe() server.Config.Services.Manager = mockManager mockQueue := queue_mocks.NewMockQueue(t) mockQueue.On("Push", mock.Anything, mock.Anything).Return(nil).Maybe() mockQueue.On("PushAtOnce", mock.Anything, mock.Anything).Return(nil).Maybe() server.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New()) // mimic the valid config data configData := []*forge_types.FileMeta{ {Name: ".woodpecker.yml", Data: []byte("when:\n event: manual\nsteps:\n test:\n image: alpine:latest\n commands:\n - echo test")}, } mockConfigService.On("Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(configData, nil) mockStore.On("GetUser", int64(1)).Return(fakeUser, nil) mockStore.On("CreatePipeline", mock.Anything).Return(nil) mockStore.On("GetPipelineLastBefore", fakeRepo, "main", mock.Anything).Return(nil, nil).Maybe() mockStore.On("ConfigPersist", mock.Anything).Return(&model.Config{ID: 1}, nil).Maybe() mockStore.On("ConfigFindIdentical", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockStore.On("PipelineConfigCreate", mock.Anything).Return(nil).Maybe() mockStore.On("WorkflowsCreate", mock.Anything).Return(nil).Maybe() mockStore.On("UpdatePipeline", mock.Anything).Return(nil).Maybe() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Set("repo", fakeRepo) c.Set("user", fakeUser) c.Request, _ = http.NewRequest(http.MethodPost, "", io.NopCloser(bytes.NewBufferString(`{"branch": "main"}`))) c.Request.Header.Set("Content-Type", "application/json") CreatePipeline(c) // verify the config service was called successfully (no error, returns config) mockConfigService.AssertCalled(t, "Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false) mockForge.AssertCalled(t, "BranchHead", mock.Anything, fakeUser, fakeRepo, "main") mockStore.AssertCalled(t, "GetUser", int64(1)) mockStore.AssertCalled(t, "CreatePipeline", mock.Anything) }) // 2. abnormal with oldconfig: config fetch fails but returns config data (error + non-nil config) -> continues with fallback t.Run("abnormal workflow - cannot read config but has oldconfig", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockConfigService := config_service_mocks.NewMockService(t) mockSecretService := secret_service_mocks.NewMockService(t) mockRegistryService := registry_service_mocks.NewMockService(t) fakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: "test/repo"} fakeUser := &model.User{ID: 1, Login: "testuser", Email: "test@example.com", Avatar: "avatar.png", Hash: "hash123"} fakeCommit := &model.Commit{SHA: "abc123", ForgeURL: "https://example.com/commit/abc123"} mockForge := forge_mocks.NewMockForge(t) mockForge.On("Name").Return("mock").Maybe() mockForge.On("URL").Return("https://example.com").Maybe() mockForge.On("BranchHead", mock.Anything, fakeUser, fakeRepo, "main").Return(fakeCommit, nil) // mock the netrc for parse config mockForge.On("Netrc", fakeUser, fakeRepo).Return(&model.Netrc{ Machine: "example.com", Login: "testuser", Password: "testpass", }, nil).Maybe() mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe() mockSecretService.On("SecretListPipeline", mock.Anything, fakeRepo, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{}, nil).Maybe() mockRegistryService.On("RegistryListPipeline", mock.Anything, fakeRepo, mock.Anything, mock.Anything).Return([]*model.Registry{}, nil).Maybe() mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) mockManager.On("ConfigServiceFromRepo", fakeRepo).Return(mockConfigService) mockManager.On("SecretServiceFromRepo", fakeRepo).Return(mockSecretService).Maybe() mockManager.On("RegistryServiceFromRepo", fakeRepo).Return(mockRegistryService).Maybe() mockManager.On("EnvironmentService").Return(nil).Maybe() server.Config.Services.Manager = mockManager mockQueue := queue_mocks.NewMockQueue(t) mockQueue.On("Push", mock.Anything, mock.Anything).Return(nil).Maybe() mockQueue.On("PushAtOnce", mock.Anything, mock.Anything).Return(nil).Maybe() server.Config.Services.Scheduler = scheduler.NewScheduler(mockQueue, memory.New()) // mimic the old config data oldConfigData := []*forge_types.FileMeta{ {Name: ".woodpecker.yml", Data: []byte("when:\n event: manual\nsteps:\n test:\n image: alpine:latest\n commands:\n - echo test")}, } mockConfigService.On("Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(oldConfigData, http.ErrHandlerTimeout) mockStore.On("GetUser", int64(1)).Return(fakeUser, nil) mockStore.On("CreatePipeline", mock.Anything).Return(nil) mockStore.On("GetPipelineLastBefore", fakeRepo, "main", mock.Anything).Return(nil, nil).Maybe() mockStore.On("ConfigPersist", mock.Anything).Return(&model.Config{ID: 1}, nil).Maybe() mockStore.On("ConfigFindIdentical", mock.Anything, mock.Anything).Return(nil, nil).Maybe() mockStore.On("PipelineConfigCreate", mock.Anything).Return(nil).Maybe() mockStore.On("WorkflowsCreate", mock.Anything).Return(nil).Maybe() mockStore.On("UpdatePipeline", mock.Anything).Return(nil).Maybe() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Set("repo", fakeRepo) c.Set("user", fakeUser) c.Request, _ = http.NewRequest(http.MethodPost, "", io.NopCloser(bytes.NewBufferString(`{"branch": "main"}`))) c.Request.Header.Set("Content-Type", "application/json") CreatePipeline(c) // verify the config service returned error + old config (fallback scenario) mockConfigService.AssertCalled(t, "Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false) mockStore.AssertCalled(t, "GetUser", int64(1)) mockStore.AssertCalled(t, "CreatePipeline", mock.Anything) }) // 3. abnormal without oldconfig: config fetch fails without config data (error + nil config) -> fails immediately t.Run("abnormal workflow - cannot read config and no oldconfig", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockConfigService := config_service_mocks.NewMockService(t) fakeRepo := &model.Repo{ID: 1, UserID: 1, FullName: "test/repo"} fakeUser := &model.User{ID: 1, Login: "testuser", Email: "test@example.com", Avatar: "avatar.png", Hash: "hash123"} fakeCommit := &model.Commit{SHA: "abc123", ForgeURL: "https://example.com/commit/abc123"} mockForge := forge_mocks.NewMockForge(t) mockForge.On("BranchHead", mock.Anything, fakeUser, fakeRepo, "main").Return(fakeCommit, nil) mockForge.On("Netrc", fakeUser, fakeRepo).Return(nil, nil).Maybe() mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe() mockManager := manager_mocks.NewMockManager(t) mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) mockManager.On("ConfigServiceFromRepo", fakeRepo).Return(mockConfigService) server.Config.Services.Manager = mockManager server.Config.Services.Scheduler = scheduler.NewScheduler(nil, memory.New()) // return nil config with error mockConfigService.On("Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false).Return(nil, http.ErrHandlerTimeout) mockStore.On("GetUser", int64(1)).Return(fakeUser, nil) mockStore.On("CreatePipeline", mock.Anything).Return(nil) mockStore.On("UpdatePipeline", mock.Anything).Return(nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Set("repo", fakeRepo) c.Set("user", fakeUser) c.Request, _ = http.NewRequest(http.MethodPost, "", io.NopCloser(bytes.NewBufferString(`{"branch": "main"}`))) c.Request.Header.Set("Content-Type", "application/json") CreatePipeline(c) // verify the config service returned error without any config data mockConfigService.AssertCalled(t, "Fetch", mock.Anything, mockForge, fakeUser, fakeRepo, mock.Anything, mock.Anything, false) mockStore.AssertCalled(t, "GetUser", int64(1)) mockStore.AssertCalled(t, "CreatePipeline", mock.Anything) mockStore.AssertCalled(t, "UpdatePipeline", mock.Anything) }) } ================================================ FILE: server/api/queue.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "fmt" "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // GetQueueInfo // // @Summary Get pipeline queue information // @Description Returns pipeline queue information with agent details // @Router /queue/info [get] // @Produce json // @Success 200 {object} QueueInfo // @Tags Pipeline queues // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func GetQueueInfo(c *gin.Context) { info := server.Config.Services.Scheduler.Info(c) _store := store.FromContext(c) // Create a map to store agent names by ID agentNameMap := make(map[int64]string) // Process tasks and add agent names pendingWithAgents, err := processQueueTasks(_store, info.Pending, agentNameMap) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } waitingWithAgents, err := processQueueTasks(_store, info.WaitingOnDeps, agentNameMap) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } runningWithAgents, err := processQueueTasks(_store, info.Running, agentNameMap) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } // Create response with agent-enhanced tasks response := model.QueueInfo{ Pending: pendingWithAgents, WaitingOnDeps: waitingWithAgents, Running: runningWithAgents, Stats: struct { WorkerCount int `json:"worker_count"` PendingCount int `json:"pending_count"` WaitingOnDepsCount int `json:"waiting_on_deps_count"` RunningCount int `json:"running_count"` }{ WorkerCount: info.Stats.Workers, PendingCount: info.Stats.Pending, WaitingOnDepsCount: info.Stats.WaitingOnDeps, RunningCount: info.Stats.Running, }, Paused: info.Paused, } c.IndentedJSON(http.StatusOK, response) } // PauseQueue // // @Summary Pause the pipeline queue // @Router /queue/pause [post] // @Produce plain // @Success 204 // @Tags Pipeline queues // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func PauseQueue(c *gin.Context) { server.Config.Services.Scheduler.Pause() c.Status(http.StatusNoContent) } // ResumeQueue // // @Summary Resume the pipeline queue // @Router /queue/resume [post] // @Produce plain // @Success 204 // @Tags Pipeline queues // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func ResumeQueue(c *gin.Context) { server.Config.Services.Scheduler.Resume() c.Status(http.StatusNoContent) } // BlockTilQueueHasRunningItem // // @Summary Block til pipeline queue has a running item // @Router /queue/norunningpipelines [get] // @Produce plain // @Success 204 // @Tags Pipeline queues // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func BlockTilQueueHasRunningItem(c *gin.Context) { for { info := server.Config.Services.Scheduler.Info(c) if info.Stats.Running == 0 { break } } c.Status(http.StatusNoContent) } // processQueueTasks converts tasks to QueueTask structs and adds agent names. func processQueueTasks(store store.Store, tasks []*model.Task, agentNameMap map[int64]string) ([]model.QueueTask, error) { result := make([]model.QueueTask, 0, len(tasks)) for _, task := range tasks { taskResponse := model.QueueTask{ Task: *task, } if task.AgentID != 0 { name, ok := getAgentName(store, agentNameMap, task.AgentID) if !ok { return nil, fmt.Errorf("agent not found for task %s", task.ID) } taskResponse.AgentName = name } if task.PipelineID != 0 { p, err := store.GetPipeline(task.PipelineID) if err != nil { return nil, fmt.Errorf("pipeline not found for task %s", task.ID) } taskResponse.PipelineNumber = p.Number } result = append(result, taskResponse) } return result, nil } ================================================ FILE: server/api/registry.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) // GetRegistry // // @Summary Get a registry by name // @Router /repos/{repo_id}/registries/{registry} [get] // @Produce json // @Success 200 {object} Registry // @Tags Repository registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param registry path string true "the registry name" func GetRegistry(c *gin.Context) { repo := session.Repo(c) addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) registry, err := registryService.RegistryFind(repo, addr) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, registry.Copy()) } // PostRegistry // // @Summary Create a registry // @Router /repos/{repo_id}/registries [post] // @Produce json // @Success 200 {object} Registry // @Tags Repository registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param registry body Registry true "the new registry data" func PostRegistry(c *gin.Context) { repo := session.Repo(c) in := new(model.Registry) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing request. %s", err) return } registry := &model.Registry{ RepoID: repo.ID, Address: in.Address, Username: in.Username, Password: in.Password, } if err := registry.Validate(); err != nil { c.String(http.StatusBadRequest, "Error inserting registry. %s", err) return } registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) if err := registryService.RegistryCreate(repo, registry); err != nil { c.String(http.StatusInternalServerError, "Error inserting registry %q. %s", in.Address, err) return } c.JSON(http.StatusOK, in.Copy()) } // PatchRegistry // // @Summary Update a registry by name // @Router /repos/{repo_id}/registries/{registry} [patch] // @Produce json // @Success 200 {object} Registry // @Tags Repository registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param registry path string true "the registry name" // @Param registryData body Registry true "the attributes for the registry" func PatchRegistry(c *gin.Context) { repo := session.Repo(c) addr := c.Param("registry") in := new(model.Registry) err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, "Error parsing request. %s", err) return } registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) registry, err := registryService.RegistryFind(repo, addr) if err != nil { handleDBError(c, err) return } if in.Username != "" { registry.Username = in.Username } if in.Password != "" { registry.Password = in.Password } if err := registry.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error updating registry. %s", err) return } if err := registryService.RegistryUpdate(repo, registry); err != nil { c.String(http.StatusInternalServerError, "Error updating registry %q. %s", in.Address, err) return } c.JSON(http.StatusOK, in.Copy()) } // GetRegistryList // // @Summary List registries // @Router /repos/{repo_id}/registries [get] // @Produce json // @Success 200 {array} Registry // @Tags Repository registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetRegistryList(c *gin.Context) { repo := session.Repo(c) registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) list, err := registryService.RegistryList(repo, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting registry list. %s", err) return } // copy the registry detail to remove the sensitive // password and token fields. for i, registry := range list { list[i] = registry.Copy() } c.JSON(http.StatusOK, list) } // DeleteRegistry // // @Summary Delete a registry by name // @Router /repos/{repo_id}/registries/{registry} [delete] // @Produce plain // @Success 204 // @Tags Repository registries // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param registry path string true "the registry name" func DeleteRegistry(c *gin.Context) { repo := session.Repo(c) addr := c.Param("registry") registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) err := registryService.RegistryDelete(repo, addr) if err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/repo.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "encoding/base32" "errors" "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "github.com/tink-crypto/tink-go/v2/subtle/random" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) // PostRepo // // @Summary Activate a repository // @Router /repos [post] // @Produce json // @Success 200 {object} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param forge_remote_id query string true "the id of a repository at the forge" func PostRepo(c *gin.Context) { _store := store.FromContext(c) user := session.User(c) _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { log.Error().Err(err).Msg("Cannot get forge from user") _ = c.AbortWithError(http.StatusInternalServerError, err) return } forge.Refresh(c, _forge, _store, user) forgeRemoteID := model.ForgeRemoteID(c.Query("forge_remote_id")) if !forgeRemoteID.IsValid() { c.String(http.StatusBadRequest, "No forge_remote_id provided") return } repo, err := _store.GetRepoForgeID(user.ForgeID, forgeRemoteID) enabledOnce := err == nil // if there's no error, the repo was found and enabled once already if enabledOnce && repo.IsActive { c.String(http.StatusConflict, "Repository is already active.") return } else if err != nil && !errors.Is(err, types.ErrRecordNotExist) { msg := "could not get repo by remote id from store." log.Error().Err(err).Msg(msg) c.String(http.StatusInternalServerError, msg) return } from, err := _forge.Repo(c, user, forgeRemoteID, "", "") if err != nil { c.String(http.StatusInternalServerError, "Could not fetch repository from forge.") return } if !from.Perm.Admin { c.String(http.StatusForbidden, "User has to be a admin of this repository") return } if !server.Config.Permissions.OwnersAllowlist.IsAllowed(from) { c.String(http.StatusForbidden, "Repo owner is not allowed") return } from.ForgeID = user.ForgeID if enabledOnce { repo.Update(from) } else { repo = from repo.RequireApproval = server.Config.Pipeline.DefaultApprovalMode repo.AllowPull = server.Config.Pipeline.DefaultAllowPullRequests repo.AllowDeploy = false repo.CancelPreviousPipelineEvents = server.Config.Pipeline.DefaultCancelPreviousPipelineEvents } repo.IsActive = true repo.UserID = user.ID if repo.Visibility == "" { repo.Visibility = model.VisibilityPublic if repo.IsSCMPrivate { repo.Visibility = model.VisibilityPrivate } } if repo.Timeout == 0 { repo.Timeout = server.Config.Pipeline.DefaultTimeout } else if repo.Timeout > server.Config.Pipeline.MaxTimeout { repo.Timeout = server.Config.Pipeline.MaxTimeout } if repo.Hash == "" { repo.Hash = base32.StdEncoding.EncodeToString( random.GetRandomBytes(32), ) } // find org of repo var org *model.Org org, err = _store.OrgFindByName(repo.Owner, user.ForgeID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { c.String(http.StatusInternalServerError, err.Error()) return } // create an org if it doesn't exist yet if errors.Is(err, types.ErrRecordNotExist) { org, err = _forge.Org(c, user, repo.Owner) if err != nil { msg := fmt.Sprintf("Organization %s not found in DB. Attempting to create new one.", repo.Owner) log.Error().Err(err).Msg(msg) c.String(http.StatusInternalServerError, msg) return } org.ForgeID = user.ForgeID err = _store.OrgCreate(org) if err != nil { msg := fmt.Sprintf("Failed to create organization %s.", repo.Owner) log.Error().Err(err).Msg(msg) c.String(http.StatusInternalServerError, msg) return } } repo.OrgID = org.ID // creates the jwt token used to verify the repository t := token.New(token.HookToken) t.Set("repo-forge-remote-id", string(forgeRemoteID)) t.Set("forge-id", strconv.FormatInt(repo.ForgeID, 10)) sig, err := t.Sign(repo.Hash) if err != nil { msg := "could not generate new jwt token." log.Error().Err(err).Msg(msg) c.String(http.StatusInternalServerError, msg) return } hookURL := fmt.Sprintf( "%s/api/hook?access_token=%s", server.Config.Server.WebhookHost, sig, ) err = _forge.Activate(c, user, repo, hookURL) if err != nil { msg := "could not create webhook in forge." log.Error().Err(err).Msg(msg) c.String(http.StatusInternalServerError, msg) return } if enabledOnce { err = _store.UpdateRepo(repo) } else { err = _store.CreateRepo(repo) } if err != nil { if errors.Is(err, types.ErrInsertDuplicateDetected) { c.String(http.StatusConflict, "Repository already exists in Woodpecker. Remove the stale repository entry and try again.") return } msg := "could not create/update repo in store." log.Error().Err(err).Msg(msg) c.String(http.StatusInternalServerError, msg) return } repo.Perm = from.Perm repo.Perm.Synced = time.Now().Unix() repo.Perm.UserID = user.ID repo.Perm.RepoID = repo.ID err = _store.PermUpsert(repo.Perm) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, repo) } // PatchRepo // // @Summary Update a repository // @Router /repos/{repo_id} [patch] // @Produce json // @Success 200 {object} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param repo body RepoPatch true "the repository's information" func PatchRepo(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) user := session.User(c) in := new(model.RepoPatch) if err := c.Bind(in); err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } if in.Timeout != nil && *in.Timeout > server.Config.Pipeline.MaxTimeout && !user.Admin { c.String(http.StatusForbidden, fmt.Sprintf("Timeout is not allowed to be higher than max timeout (%d min)", server.Config.Pipeline.MaxTimeout)) return } if in.Trusted != nil { // if user is not admin if !user.Admin && // and some trusted settings got changed ((in.Trusted.Network != nil && *in.Trusted.Network != repo.Trusted.Network) || (in.Trusted.Volumes != nil && *in.Trusted.Volumes != repo.Trusted.Volumes) || (in.Trusted.Security != nil && *in.Trusted.Security != repo.Trusted.Security)) { log.Trace().Msgf("user '%s' wants to change trusted without being an instance admin", user.Login) // return error c.String(http.StatusForbidden, "Insufficient privileges") return } if in.Trusted.Network != nil { repo.Trusted.Network = *in.Trusted.Network } if in.Trusted.Security != nil { repo.Trusted.Security = *in.Trusted.Security } if in.Trusted.Volumes != nil { repo.Trusted.Volumes = *in.Trusted.Volumes } } if in.AllowPull != nil { repo.AllowPull = *in.AllowPull } if in.AllowDeploy != nil { repo.AllowDeploy = *in.AllowDeploy } if in.RequireApproval != nil { if mode := model.ApprovalMode(*in.RequireApproval); mode.Valid() { repo.RequireApproval = mode } else { c.String(http.StatusBadRequest, "Invalid require-approval setting") return } } if in.ApprovalAllowedUsers != nil { repo.ApprovalAllowedUsers = *in.ApprovalAllowedUsers } if in.Timeout != nil { repo.Timeout = *in.Timeout } if in.Config != nil { repo.Config = *in.Config } if in.CancelPreviousPipelineEvents != nil { repo.CancelPreviousPipelineEvents = *in.CancelPreviousPipelineEvents } if in.NetrcTrusted != nil { repo.NetrcTrustedPlugins = *in.NetrcTrusted } if in.Visibility != nil { switch *in.Visibility { case string(model.VisibilityInternal), string(model.VisibilityPrivate), string(model.VisibilityPublic): repo.Visibility = model.RepoVisibility(*in.Visibility) default: c.String(http.StatusBadRequest, "Invalid visibility type") return } } if in.ConfigExtensionEndpoint != nil { repo.ConfigExtensionEndpoint = *in.ConfigExtensionEndpoint } if in.ConfigExtensionExclusive != nil { repo.ConfigExtensionExclusive = *in.ConfigExtensionExclusive } if in.ConfigExtensionNetrc != nil { repo.ConfigExtensionNetrc = *in.ConfigExtensionNetrc } if in.RegistryExtensionEndpoint != nil { repo.RegistryExtensionEndpoint = *in.RegistryExtensionEndpoint } if in.RegistryExtensionNetrc != nil { repo.RegistryExtensionNetrc = *in.RegistryExtensionNetrc } if in.SecretExtensionEndpoint != nil { repo.SecretExtensionEndpoint = *in.SecretExtensionEndpoint } if in.SecretExtensionNetrc != nil { repo.SecretExtensionNetrc = *in.SecretExtensionNetrc } err := _store.UpdateRepo(repo) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, repo) } // ChownRepo // // @Summary Change a repository's owner to the currently authenticated user // @Router /repos/{repo_id}/chown [post] // @Produce json // @Success 200 {object} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" func ChownRepo(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) user := session.User(c) repo.UserID = user.ID err := _store.UpdateRepo(repo) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, repo) } // LookupRepo // // @Summary Lookup a repository by full name // @Router /repos/lookup/{repo_full_name} [get] // @Produce json // @Success 200 {object} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_full_name path string true "the repository full name / slug" func LookupRepo(c *gin.Context) { c.JSON(http.StatusOK, session.Repo(c)) } // GetRepo // // @Summary Get a repository // @Router /repos/{repo_id} [get] // @Produce json // @Success 200 {object} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" func GetRepo(c *gin.Context) { c.JSON(http.StatusOK, session.Repo(c)) } // GetRepoPermissions // // @Summary Check current authenticated users access to the repository // @Description The repository permission, according to the used access token. // @Router /repos/{repo_id}/permissions [get] // @Produce json // @Success 200 {object} Perm // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" func GetRepoPermissions(c *gin.Context) { perm := session.Perm(c) c.JSON(http.StatusOK, perm) } // GetRepoBranches // // @Summary Get branches of a repository // @Router /repos/{repo_id}/branches [get] // @Produce json // @Success 200 {array} string // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetRepoBranches(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") _ = c.AbortWithError(http.StatusInternalServerError, err) return } repoUser, err := _store.GetUser(repo.UserID) if err != nil { handleDBError(c, err) return } forge.Refresh(c, _forge, _store, repoUser) branches, err := _forge.Branches(c, repoUser, repo, session.Pagination(c)) if errors.Is(err, forge_types.ErrNotImplemented) { log.Debug().Msg("Could not fetch repo branch list as forge adapter did not implement it") } else if err != nil { log.Error().Err(err).Msg("failed to load branches") c.String(http.StatusInternalServerError, "failed to load branches: %s", err) return } c.JSON(http.StatusOK, branches) } // GetRepoPullRequests // // @Summary List active pull requests of a repository // @Router /repos/{repo_id}/pull_requests [get] // @Produce json // @Success 200 {array} PullRequest // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetRepoPullRequests(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") _ = c.AbortWithError(http.StatusInternalServerError, err) return } repoUser, err := _store.GetUser(repo.UserID) if err != nil { handleDBError(c, err) return } forge.Refresh(c, _forge, _store, repoUser) prs, err := _forge.PullRequests(c, repoUser, repo, session.Pagination(c)) if errors.Is(err, forge_types.ErrNotImplemented) { log.Debug().Msg("Could not fetch repo pull-request list as forge adapter did not implement it") } else if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.JSON(http.StatusOK, prs) } // DeleteRepo // // @Summary Delete a repository // @Router /repos/{repo_id} [delete] // @Produce json // @Success 200 {object} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" func DeleteRepo(c *gin.Context) { remove, _ := strconv.ParseBool(c.Query("remove")) _store := store.FromContext(c) repo := session.Repo(c) user := session.User(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") _ = c.AbortWithError(http.StatusInternalServerError, err) return } forge.Refresh(c, _forge, _store, user) if err := _forge.Deactivate(c, user, repo, server.Config.Server.WebhookHost); err != nil { log.Error().Err(err).Msgf("could not deactivate repo [%d] on forge", repo.ID) // in case we want to delete the repo on our side we should not worry to much on the forge side // also if we get signalized that the repo on the forge is gone we can just ignore that if errors.Is(err, forge_types.ErrRepoNotFound) || remove { log.Debug().Msg("ignore deactivating repo on forge") } else { _ = c.AbortWithError(http.StatusInternalServerError, err) return } } if remove { if err := _store.DeleteRepo(repo); err != nil { handleDBError(c, err) return } } else { repo.IsActive = false repo.UserID = 0 if err := _store.UpdateRepo(repo); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } } c.JSON(http.StatusOK, repo) } // RepairRepo // // @Summary Repair a repository // @Router /repos/{repo_id}/repair [post] // @Produce plain // @Success 204 // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" func RepairRepo(c *gin.Context) { repo := session.Repo(c) err := repairRepo(c, repo, true) if err != nil { log.Error().Err(err).Msgf("repair repo '%s' failed", repo.FullName) _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.Status(http.StatusNoContent) } // MoveRepo // // @Summary Move a repository to a new owner // @Router /repos/{repo_id}/move [post] // @Produce plain // @Success 204 // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param to query string true "the username to move the repository to" func MoveRepo(c *gin.Context) { _store := store.FromContext(c) repo := session.Repo(c) user := session.User(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") _ = c.AbortWithError(http.StatusInternalServerError, err) return } forge.Refresh(c, _forge, _store, user) to, exists := c.GetQuery("to") if !exists { err := fmt.Errorf("missing required to query value") _ = c.AbortWithError(http.StatusInternalServerError, err) return } owner, name, errParse := model.ParseRepo(to) if errParse != nil { _ = c.AbortWithError(http.StatusInternalServerError, errParse) return } from, err := _forge.Repo(c, user, "", owner, name) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } from.ForgeID = repo.ForgeID if !from.Perm.Admin { c.AbortWithStatus(http.StatusUnauthorized) return } err = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName}) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } repo.Update(from) errStore := _store.UpdateRepo(repo) if errStore != nil { _ = c.AbortWithError(http.StatusInternalServerError, errStore) return } repo.Perm = from.Perm repo.Perm.Synced = time.Now().Unix() repo.Perm.UserID = user.ID repo.Perm.RepoID = repo.ID errStore = _store.PermUpsert(repo.Perm) if errStore != nil { _ = c.AbortWithError(http.StatusInternalServerError, errStore) return } // creates the jwt token used to verify the repository t := token.New(token.HookToken) t.Set("repo-forge-remote-id", string(repo.ForgeRemoteID)) t.Set("forge-id", strconv.FormatInt(repo.ForgeID, 10)) sig, err := t.Sign(repo.Hash) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } // reconstruct the hook url host := server.Config.Server.WebhookHost hookURL := fmt.Sprintf( "%s/api/hook?access_token=%s", host, sig, ) if err := _forge.Deactivate(c, user, repo, host); err != nil { log.Trace().Err(err).Msgf("deactivate repo '%s' for move to activate later, got an error", strconv.FormatInt(repo.ID, 10)) } if err := _forge.Activate(c, user, repo, hookURL); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.Status(http.StatusNoContent) } // GetAllRepos // // @Summary List all repositories on the server // @Description Returns a list of all repositories. Requires admin rights. // @Router /repos [get] // @Produce json // @Success 200 {array} Repo // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param active query bool false "only list active repos" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetAllRepos(c *gin.Context) { _store := store.FromContext(c) active, _ := strconv.ParseBool(c.Query("active")) repos, err := _store.RepoListAll(active, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error fetching repository list. %s", err) return } c.JSON(http.StatusOK, repos) } // RepairAllRepos // // @Summary Repair all repositories on the server // @Description Executes a repair process on all repositories. Requires admin rights. // @Router /repos/repair [post] // @Produce plain // @Success 204 // @Tags Repositories // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func RepairAllRepos(c *gin.Context) { _store := store.FromContext(c) repos, err := _store.RepoListAll(true, &model.ListOptions{All: true}) if err != nil { c.String(http.StatusInternalServerError, "Error fetching repository list. %s", err) return } failedRepos := make([]int64, 0) for _, r := range repos { // updatePermissions is false as RepoListAll does not load permissions updatePermissions := false err := repairRepo(c, r, updatePermissions) if err != nil { failedRepos = append(failedRepos, r.ID) _ = c.Error(err) log.Error().Err(err).Msgf("failed to repair repo '%s'", r.FullName) } } if len(failedRepos) > 0 { c.JSON(http.StatusInternalServerError, map[string]any{ "error": "failed to repair some repos", "failed_repos": failedRepos, }) } else { c.Status(http.StatusNoContent) } } func repairRepo(c *gin.Context, repo *model.Repo, updatePermissions bool) error { _store := store.FromContext(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") return err } repoUser, err := repairRepoUser(c, repo, _store) if err != nil { log.Error().Err(err).Msgf("cannot assign user to repo '%s'", repo.FullName) return err } // refresh user token if necessary forge.Refresh(c, _forge, _store, repoUser) // creates a new jwt token used to verify webhook calls t := token.New(token.HookToken) t.Set("repo-forge-remote-id", string(repo.ForgeRemoteID)) t.Set("forge-id", strconv.FormatInt(repo.ForgeID, 10)) sig, err := t.Sign(repo.Hash) if err != nil { return err } // reconstruct the webhook url host := server.Config.Server.WebhookHost hookURL := fmt.Sprintf( "%s/api/hook?access_token=%s", host, sig, ) from, err := _forge.Repo(c, repoUser, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { // If we have valid ForgeRemoteID and can not find the repo, // we assume the repo was deleted and try to get a new one if it was re-created. if errors.Is(err, forge_types.ErrRepoNotFound) && repo.ForgeRemoteID.IsValid() { from, err = _forge.Repo(c, repoUser, "", repo.Owner, repo.Name) if err == nil { log.Debug().Str("repoFullName", repo.FullName). Str("old ForgeRemoteID", string(repo.ForgeRemoteID)).Str("new ForgeRemoteID", string(from.ForgeRemoteID)). Msgf("RepoRepair detected remote repo ID change and updated it") } } } if err != nil { log.Error().Err(err).Msgf("get repo '%s/%s' from forge", repo.Owner, repo.Name) return fmt.Errorf("fetching repo from forge: %w", err) } from.ForgeID = repo.ForgeID if repo.FullName != from.FullName { // create a redirection err = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName}) if err != nil { return err } } repo.Update(from) if err := _store.UpdateRepo(repo); err != nil { return err } if updatePermissions { repo.Perm = from.Perm repo.Perm.Synced = time.Now().Unix() repo.Perm.UserID = repoUser.ID repo.Perm.RepoID = repo.ID if err := _store.PermUpsert(repo.Perm); err != nil { return err } } // remove webhook (deactivate) and recreate it (activate) if err := _forge.Deactivate(c, repoUser, repo, host); err != nil { log.Debug().Err(err).Msgf("deactivate repo '%s' to repair failed", repo.FullName) } return _forge.Activate(c, repoUser, repo, hookURL) } func repairRepoUser(c *gin.Context, repo *model.Repo, _store store.Store) (*model.User, error) { repoUser, err := _store.GetUser(repo.UserID) if err != nil { if errors.Is(err, types.ErrRecordNotExist) { oldUserID := repo.UserID sessionUser := session.User(c) repo.UserID = sessionUser.ID err = _store.UpdateRepo(repo) if err != nil { return nil, err } log.Debug().Msgf("Could not find repo user with ID %d during repo repair, set to repair request user with ID %d", oldUserID, sessionUser.ID) return sessionUser, nil } return nil, err } return repoUser, nil } ================================================ FILE: server/api/repo_secret.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) // GetSecret // // @Summary Get a repository secret by name // @Router /repos/{repo_id}/secrets/{secretName} [get] // @Produce json // @Success 200 {object} Secret // @Tags Repository secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param secretName path string true "the secret name" func GetSecret(c *gin.Context) { repo := session.Repo(c) name := c.Param("secret") secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) secret, err := secretService.SecretFind(repo, name) if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, secret.Copy()) } // PostSecret // // @Summary Create a repository secret // @Router /repos/{repo_id}/secrets [post] // @Produce json // @Success 200 {object} Secret // @Tags Repository secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param secret body Secret true "the new secret" func PostSecret(c *gin.Context) { repo := session.Repo(c) in := new(model.Secret) if err := c.Bind(in); err != nil { c.String(http.StatusBadRequest, "Error parsing secret. %s", err) return } secret := &model.Secret{ RepoID: repo.ID, Name: in.Name, Value: in.Value, Events: in.Events, Images: in.Images, Note: in.Note, } if err := secret.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error inserting secret. %s", err) return } secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) if err := secretService.SecretCreate(repo, secret); err != nil { c.String(http.StatusInternalServerError, "Error inserting secret %q. %s", in.Name, err) return } c.JSON(http.StatusOK, secret.Copy()) } // PatchSecret // // @Summary Update a repository secret by name // @Router /repos/{repo_id}/secrets/{secretName} [patch] // @Produce json // @Success 200 {object} Secret // @Tags Repository secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param secretName path string true "the secret name" // @Param secret body SecretPatch true "the secret itself" func PatchSecret(c *gin.Context) { var ( repo = session.Repo(c) name = c.Param("secret") ) in := new(model.SecretPatch) err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, "Error parsing secret. %s", err) return } secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) secret, err := secretService.SecretFind(repo, name) if err != nil { handleDBError(c, err) return } if in.Value != nil && *in.Value != "" { secret.Value = *in.Value } if in.Events != nil { secret.Events = in.Events } if in.Images != nil { secret.Images = in.Images } if in.Note != nil { secret.Note = *in.Note } if err := secret.Validate(); err != nil { c.String(http.StatusUnprocessableEntity, "Error updating secret. %s", err) return } if err := secretService.SecretUpdate(repo, secret); err != nil { c.String(http.StatusInternalServerError, "Error updating secret %q. %s", in.Name, err) return } c.JSON(http.StatusOK, secret.Copy()) } // GetSecretList // // @Summary List repository secrets // @Router /repos/{repo_id}/secrets [get] // @Produce json // @Success 200 {array} Secret // @Tags Repository secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetSecretList(c *gin.Context) { repo := session.Repo(c) secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) list, err := secretService.SecretList(repo, session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting secret list. %s", err) return } // copy the secret detail to remove the sensitive // password and token fields. for i, secret := range list { list[i] = secret.Copy() } c.JSON(http.StatusOK, list) } // DeleteSecret // // @Summary Delete a repository secret by name // @Router /repos/{repo_id}/secrets/{secretName} [delete] // @Produce plain // @Success 204 // @Tags Repository secrets // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param repo_id path int true "the repository id" // @Param secretName path string true "the secret name" func DeleteSecret(c *gin.Context) { repo := session.Repo(c) name := c.Param("secret") secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) if err := secretService.SecretDelete(repo, name); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/repo_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/model" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/services/permissions" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestPostRepoReturnsConflictOnDuplicateRepository(t *testing.T) { gin.SetMode(gin.TestMode) mockStore := store_mocks.NewMockStore(t) mockManager := manager_mocks.NewMockManager(t) mockForge := forge_mocks.NewMockForge(t) server.Config.Services.Manager = mockManager server.Config.Permissions.OwnersAllowlist = permissions.NewOwnersAllowlist(nil) server.Config.Server.WebhookHost = "https://woodpecker.example" server.Config.Pipeline.DefaultApprovalMode = model.RequireApprovalForks server.Config.Pipeline.DefaultAllowPullRequests = true server.Config.Pipeline.DefaultCancelPreviousPipelineEvents = nil server.Config.Pipeline.DefaultTimeout = 60 server.Config.Pipeline.MaxTimeout = 120 user := &model.User{ID: 10, ForgeID: 7, Login: "alice"} forgeRemoteID := model.ForgeRemoteID("42") forgeRepo := &model.Repo{ ForgeRemoteID: forgeRemoteID, Owner: "acme", Name: "rocket", FullName: "acme/rocket", Perm: &model.Perm{Admin: true}, } org := &model.Org{ID: 3, Name: "acme", ForgeID: user.ForgeID} mockManager.On("ForgeFromUser", user).Return(mockForge, nil) mockStore.On("GetRepoForgeID", user.ForgeID, forgeRemoteID).Return(nil, types.ErrRecordNotExist) mockForge.On("Repo", mock.Anything, user, forgeRemoteID, "", "").Return(forgeRepo, nil) mockStore.On("OrgFindByName", forgeRepo.Owner, user.ForgeID).Return(org, nil) mockForge.On("Activate", mock.Anything, user, mock.AnythingOfType("*model.Repo"), mock.AnythingOfType("string")).Return(nil) mockStore.On("CreateRepo", mock.AnythingOfType("*model.Repo")).Return(types.ErrInsertDuplicateDetected) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Set("store", mockStore) c.Set("user", user) c.Request = httptest.NewRequest(http.MethodPost, "/repos?forge_remote_id=42", nil) PostRepo(c) assert.Equal(t, http.StatusConflict, w.Code) assert.Contains(t, w.Body.String(), "Remove the stale repository entry") mockStore.AssertNotCalled(t, "PermUpsert", mock.Anything) } ================================================ FILE: server/api/signature_public_key.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "crypto/x509" "encoding/pem" "net/http" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" ) // GetSignaturePublicKey // // @Summary Get server's signature public key // @Router /signature/public-key [get] // @Produce plain // @Success 200 // @Tags System // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func GetSignaturePublicKey(c *gin.Context) { b, err := x509.MarshalPKIXPublicKey(server.Config.Services.Manager.SignaturePublicKey()) if err != nil { log.Error().Err(err).Msg("can't marshal public key") c.AbortWithStatus(http.StatusInternalServerError) return } block := &pem.Block{ Type: "PUBLIC KEY", Bytes: b, } c.String(http.StatusOK, "%s", pem.EncodeToMemory(block)) } ================================================ FILE: server/api/stream.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "context" "encoding/json" "errors" "io" "net/http" "strconv" "sync" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) const ( // How many batches of logs to keep for each client before starting to // drop them if the client is not consuming them faster than they arrive. maxQueuedBatchesPerClient int = 30 // Is the time till we send a ping to keep the connection alive. idlePingTime = time.Second * 30 ) // EventStreamSSE // // @Summary Stream events like pipeline updates // @Description With quic and http2 support // @Router /stream/events [get] // @Produce plain // @Success 200 // @Tags Events func EventStreamSSE(c *gin.Context) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-store") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") rw := c.Writer flusher, ok := rw.(http.Flusher) if !ok { c.String(http.StatusInternalServerError, "Streaming not supported") return } // ping the client logWriteStringErr(io.WriteString(rw, ": ping\n\n")) flusher.Flush() log.Debug().Msg("user feed: connection opened") user := session.User(c) subTopics := make(map[string]struct{}) // subscribe to all public state changes subTopics[pubsub.PublicTopic] = struct{}{} // subscribe to all private state changes or repos the user owns if user != nil { repos, _ := store.FromContext(c).RepoList(user, false, true, nil) for _, r := range repos { subTopics[pubsub.GetRepoTopic(r)] = struct{}{} } } eventChan := make(chan []byte, 10) ctx, cancel := context.WithCancelCause( context.Background(), ) requestCtx := c.Request.Context() defer func() { cancel(nil) log.Debug().Msg("user feed: connection closed") }() go func() { err := server.Config.Services.Scheduler.Subscribe(ctx, subTopics, func(m pubsub.Message) { select { case <-ctx.Done(): case eventChan <- m.Data: } }) cancel(err) }() for { select { case <-requestCtx.Done(): return case <-ctx.Done(): return case <-time.After(idlePingTime): logWriteStringErr(io.WriteString(rw, ": ping\n\n")) flusher.Flush() case buf, ok := <-eventChan: if ok { logWriteStringErr(io.WriteString(rw, "data: ")) logWriteStringErr(rw.Write(buf)) logWriteStringErr(io.WriteString(rw, "\n\n")) flusher.Flush() } } } } // LogStreamSSE // // @Summary Stream logs of a pipeline step // @Router /stream/logs/{repo_id}/{pipeline}/{step_id} [get] // @Produce plain // @Success 200 // @Tags Pipeline logs // @Param repo_id path int true "the repository id" // @Param pipeline path int true "the number of the pipeline" // @Param step_id path int true "the step id" func LogStreamSSE(c *gin.Context) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") rw := c.Writer flusher, ok := rw.(http.Flusher) if !ok { c.String(http.StatusInternalServerError, "Streaming not supported") return } logWriteStringErr(io.WriteString(rw, ": ping\n\n")) flusher.Flush() _store := store.FromContext(c) repo := session.Repo(c) pipeline, err := strconv.ParseInt(c.Param("pipeline"), 10, 64) if err != nil { log.Debug().Err(err).Msg("pipeline number invalid") logWriteStringErr(io.WriteString(rw, "event: error\ndata: pipeline number invalid\n\n")) return } pl, err := _store.GetPipelineNumber(repo, pipeline) if err != nil { log.Debug().Err(err).Msg("stream cannot get pipeline number") logWriteStringErr(io.WriteString(rw, "event: error\ndata: pipeline not found\n\n")) return } stepID, err := strconv.ParseInt(c.Param("step_id"), 10, 64) if err != nil { log.Debug().Err(err).Msg("step id invalid") logWriteStringErr(io.WriteString(rw, "event: error\ndata: step id invalid\n\n")) return } step, err := _store.StepLoad(pl.ID, stepID) if err != nil { log.Debug().Err(err).Msg("stream cannot get step number") logWriteStringErr(io.WriteString(rw, "event: error\ndata: process not found\n\n")) return } if step.State != model.StatusPending && step.State != model.StatusRunning { log.Debug().Msg("step not running (anymore).") logWriteStringErr(io.WriteString(rw, "event: error\ndata: step not running (anymore)\n\n")) return } logChan := make(chan []byte, 10) ctx, cancel := context.WithCancelCause( context.Background(), ) requestCtx := c.Request.Context() log.Debug().Msg("log stream: connection opened") defer func() { cancel(nil) log.Debug().Msg("log stream: connection closed") }() err = server.Config.Services.Logs.Open(ctx, step.ID) if err != nil { log.Error().Err(err).Msg("log stream: open failed") logWriteStringErr(io.WriteString(rw, "event: error\ndata: can't open stream\n\n")) return } go func() { batches := make(logging.LogChan, maxQueuedBatchesPerClient) var innerDone sync.WaitGroup innerDone.Add(1) go func() { defer innerDone.Done() for entries := range batches { for _, entry := range entries { if ee, err := json.Marshal(entry); err == nil { select { case <-ctx.Done(): return case logChan <- ee: } } else { log.Error().Err(err).Msg("unable to serialize log entry") } } } }() err := server.Config.Services.Logs.Tail(ctx, step.ID, batches) if err != nil { log.Error().Err(err).Msg("tail of logs failed") } close(batches) innerDone.Wait() cancel(err) }() id := 1 last, _ := strconv.Atoi( c.Request.Header.Get("Last-Event-ID"), ) if last != 0 { log.Debug().Msgf("log stream: reconnect: last-event-id: %d", last) } for { select { case <-ctx.Done(): // Monitor if the "tail" context is canceled. if err := context.Cause(ctx); errors.Is(err, context.Canceled) { log.Debug().Msg("log stream: eof") logWriteStringErr(io.WriteString(rw, "event: eof\ndata: eof\n\n")) flusher.Flush() return } case <-requestCtx.Done(): // Monitor the request context for cancellation when the client has gone away. log.Debug().Msg("log stream: closed, client has gone away") return case <-time.After(idlePingTime): logWriteStringErr(io.WriteString(rw, ": ping\n\n")) flusher.Flush() case buf, ok := <-logChan: if ok { if id > last { logWriteStringErr(io.WriteString(rw, "id: "+strconv.Itoa(id))) logWriteStringErr(io.WriteString(rw, "\n")) logWriteStringErr(io.WriteString(rw, "data: ")) logWriteStringErr(rw.Write(buf)) logWriteStringErr(io.WriteString(rw, "\n\n")) flusher.Flush() } id++ } } } } func logWriteStringErr(_ int, err error) { if err != nil { log.Error().Err(err).Caller(1).Msg("fail to write string") } } ================================================ FILE: server/api/stream_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "context" "fmt" "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestEventStreamSSEConcurrentDisconnect(t *testing.T) { gin.SetMode(gin.TestMode) broker := memory.New() server.Config.Services.Scheduler = scheduler.NewScheduler(nil, broker) t.Cleanup(func() { server.Config.Services.Scheduler = nil }) for i := range 50 { t.Run(fmt.Sprint(i), func(t *testing.T) { t.Parallel() w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) ctx, cancel := context.WithCancelCause(t.Context()) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/stream/events", nil) c.Request = req topic := map[string]struct{}{pubsub.PublicTopic: {}} done := make(chan struct{}) go func() { defer close(done) EventStreamSSE(c) }() // Let the event handler subscribe time.Sleep(20 * time.Millisecond) // Fire concurrent publishes while canceling the request. var wg sync.WaitGroup for range 20 { wg.Add(1) go func() { defer wg.Done() _ = broker.Publish(ctx, topic, pubsub.Message{ Data: []byte(`{"pipeline":1}`), }) }() } // Simulate client disconnect mid-publish. cancel(nil) wg.Wait() <-done }) } } func setupLogStreamContext(t *testing.T) (*httptest.ResponseRecorder, *gin.Context, context.CancelCauseFunc) { t.Helper() const stepID int64 = 42 const pipelineID int64 = 10 mockStore := store_mocks.NewMockStore(t) mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything). Return(&model.Pipeline{ID: pipelineID}, nil) mockStore.On("StepLoad", mock.Anything, mock.Anything). Return(&model.Step{ ID: stepID, PipelineID: pipelineID, State: model.StatusRunning, }, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) ctx, cancel := context.WithCancelCause(t.Context()) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/stream/logs/1/1/42", nil) c.Request = req c.Params = gin.Params{ {Key: "repo_id", Value: "1"}, {Key: "pipeline", Value: "1"}, {Key: "step_id", Value: "42"}, } c.Set("repo", &model.Repo{ID: 1, FullName: "owner/repo"}) c.Set("store", mockStore) return w, c, cancel } func TestLogStreamSSEConcurrentDisconnect(t *testing.T) { gin.SetMode(gin.TestMode) logService := logging.New() server.Config.Services.Logs = logService t.Cleanup(func() { server.Config.Services.Logs = nil }) const stepID int64 = 42 for i := range 50 { t.Run(fmt.Sprint(i), func(t *testing.T) { t.Parallel() done := make(chan struct{}) _, c, cancel := setupLogStreamContext(t) go func() { defer close(done) LogStreamSSE(c) }() // Let LogStreamSSE open the stream and start tailing. time.Sleep(20 * time.Millisecond) // Fire concurrent log writes while canceling the request. var wg sync.WaitGroup for i := range 20 { wg.Add(1) go func() { defer wg.Done() _ = logService.Write(t.Context(), stepID, []*model.LogEntry{ {Line: i, Data: []byte("log line")}, }) }() } // Simulate client disconnect mid-write. cancel(nil) wg.Wait() <-done }) } } ================================================ FILE: server/api/user.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "encoding/base32" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "github.com/tink-crypto/tink-go/v2/subtle/random" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/token" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) // GetSelf // // @Summary Get the currently authenticated user // @Router /user [get] // @Produce json // @Success 200 {object} User // @Tags User // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func GetSelf(c *gin.Context) { c.JSON(http.StatusOK, session.User(c)) } // GetFeed // // @Summary Get the currently authenticated users pipeline feed // @Description The feed lists the most recent pipeline for the currently authenticated user. // @Router /user/feed [get] // @Produce json // @Success 200 {array} Feed // @Tags User // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func GetFeed(c *gin.Context) { _store := store.FromContext(c) user := session.User(c) latest, _ := strconv.ParseBool(c.Query("latest")) if latest { feed, err := _store.RepoListLatest(user) if err != nil { c.String(http.StatusInternalServerError, "Error fetching feed. %s", err) } else { c.JSON(http.StatusOK, feed) } return } feed, err := _store.UserFeed(user) if err != nil { c.String(http.StatusInternalServerError, "Error fetching user feed. %s", err) return } c.JSON(http.StatusOK, feed) } // GetRepos // // @Summary Get user's repositories // @Description Retrieve the currently authenticated User's Repository list // @Router /user/repos [get] // @Produce json // @Success 200 {array} RepoLastPipeline // @Tags User // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param all query bool false "query all repos, including inactive ones" // @Param name query string false "filter repos by name" func GetRepos(c *gin.Context) { _store := store.FromContext(c) user := session.User(c) _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { log.Error().Err(err).Msg("Cannot get forge from user") c.AbortWithStatus(http.StatusInternalServerError) return } all, _ := strconv.ParseBool(c.Query("all")) filter := &model.RepoFilter{ Name: c.Query("name"), } if all { dbRepos, err := _store.RepoList(user, true, false, filter) if err != nil { c.String(http.StatusInternalServerError, "Error fetching repository list. %s", err) return } dbReposMap := map[model.ForgeRemoteID]*model.Repo{} dbStaleReposMap := map[int64]*model.Repo{} dbReposFullNameMap := map[string]*model.Repo{} for _, r := range dbRepos { dbReposMap[r.ForgeRemoteID] = r dbReposFullNameMap[strings.ToLower(r.FullName)] = r dbStaleReposMap[r.ID] = r } _repos, err := utils.Paginate(func(page int) ([]*model.Repo, error) { return _forge.Repos(c, user, &model.ListOptions{ Page: page, PerPage: perPage, }) }, maxPage) if err != nil { c.String(http.StatusInternalServerError, "Error fetching repository list. %s", err) return } var repos []*model.Repo for _, r := range _repos { // make sure forgeID is set r.ForgeID = user.ForgeID if r.Perm.Push && server.Config.Permissions.OwnersAllowlist.IsAllowed(r) { if existingRepo := dbReposMap[r.ForgeRemoteID]; existingRepo != nil { // update repo with forge response existingRepo.Update(r) // re-apply active info existingRepo.IsActive = dbReposMap[r.ForgeRemoteID].IsActive // add to final return list repos = append(repos, existingRepo) // not stale, so remove it delete(dbStaleReposMap, existingRepo.ID) } else if r.Perm.Admin { // you must be admin of the remote repo to enable the repo repos = append(repos, r) } } } // detect conflicts for _, r := range repos { // calc if we have a remote repo with different remote id but same name as a stored one if existingRepo := dbReposFullNameMap[strings.ToLower(r.FullName)]; existingRepo != nil && existingRepo.ForgeRemoteID != r.ForgeRemoteID { r.ID = existingRepo.ID r.HasForgeNameConflict = true // not stale, so remove it delete(dbStaleReposMap, existingRepo.ID) } } // return stale repos for _, staleRepo := range dbStaleReposMap { staleRepo.HasNoForgeRepo = true repos = append(repos, staleRepo) } c.JSON(http.StatusOK, repos) return } activeRepos, err := _store.RepoList(user, true, true, filter) if err != nil { c.String(http.StatusInternalServerError, "Error fetching repository list. %s", err) return } repoIDs := make([]int64, len(activeRepos)) for i, repo := range activeRepos { repoIDs[i] = repo.ID } pipelines, err := _store.GetRepoLatestPipelines(repoIDs) if err != nil { c.String(http.StatusInternalServerError, "Error fetching repository list. %s", err) return } latestPipelines := make(map[int64]*model.Pipeline, len(activeRepos)) for _, pipeline := range pipelines { latestPipelines[pipeline.RepoID] = pipeline } repos := make([]*model.RepoLastPipeline, len(activeRepos)) for i, repo := range activeRepos { var lastAPIPipeline *model.APIPipeline lastPipeline, ok := latestPipelines[repo.ID] if ok { lastAPIPipeline = lastPipeline.ToAPIModel() } repos[i] = &model.RepoLastPipeline{ Repo: repo, LastPipeline: lastAPIPipeline, } } c.JSON(http.StatusOK, repos) } // PostToken // // @Summary Return the token of the current user as string // @Router /user/token [post] // @Produce plain // @Success 200 // @Tags User // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func PostToken(c *gin.Context) { user := session.User(c) t := token.New(token.UserToken) t.Set("user-id", strconv.FormatInt(user.ID, 10)) tokenString, err := t.Sign(user.Hash) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.String(http.StatusOK, tokenString) } // DeleteToken // // @Summary Reset a token // @Description Reset's the current personal access token of the user and returns a new one. // @Router /user/token [delete] // @Produce plain // @Success 200 // @Tags User // @Param Authorization header string true "Insert your personal access token" default(Bearer ) func DeleteToken(c *gin.Context) { _store := store.FromContext(c) user := session.User(c) user.Hash = base32.StdEncoding.EncodeToString( random.GetRandomBytes(32), ) if err := _store.UpdateUser(user); err != nil { c.String(http.StatusInternalServerError, "Error revoking tokens. %s", err) return } t := token.New(token.UserToken) t.Set("user-id", strconv.FormatInt(user.ID, 10)) tokenString, err := t.Sign(user.Hash) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } c.String(http.StatusOK, tokenString) } ================================================ FILE: server/api/users.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "encoding/base32" "errors" "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/tink-crypto/tink-go/v2/subtle/random" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) const defaultForgeID = 1 // GetUsers // // @Summary List users // @Description Returns all registered, active users in the system. Requires admin rights. // @Router /users [get] // @Produce json // @Success 200 {array} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param page query int false "for response pagination, page offset number" default(1) // @Param perPage query int false "for response pagination, max items per page" default(50) func GetUsers(c *gin.Context) { users, err := store.FromContext(c).GetUserList(session.Pagination(c)) if err != nil { c.String(http.StatusInternalServerError, "Error getting user list. %s", err) return } c.JSON(http.StatusOK, users) } // GetUser // // @Summary Get a user // @Description Returns a user with the specified login name. Requires admin rights. // @Router /users/{login} [get] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param forge_id query string true "specify forge (else default will be used)" // @Param forge_remote_id query string false "specify user id at forge (else fallback to login)" func GetUser(c *gin.Context) { forgeID, err := strconv.ParseInt(c.DefaultQuery("forge_id", fmt.Sprint(defaultForgeID)), 10, 64) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } forgeRemoteID := model.ForgeRemoteID(c.Query("forge_remote_id")) var user *model.User if forgeRemoteID.IsValid() { user, err = store.FromContext(c).GetUserByRemoteID(forgeID, forgeRemoteID) } else { user, err = store.FromContext(c).GetUserByLogin(forgeID, c.Param("login")) } if err != nil { handleDBError(c, err) return } c.JSON(http.StatusOK, user) } // PatchUser // // @Summary Update a user // @Description Changes the data of an existing user. Requires admin rights. // @Router /users/{login} [patch] // @Produce json // @Accept json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param user body User true "the user's data" func PatchUser(c *gin.Context) { _store := store.FromContext(c) in := &model.User{} err := c.Bind(in) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } if in.ForgeID < defaultForgeID { in.ForgeID = defaultForgeID } user, err := store.FromContext(c).GetUserByRemoteID(in.ForgeID, in.ForgeRemoteID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { handleDBError(c, err) return } if user == nil { user, err = _store.GetUserByLogin(in.ForgeID, c.Param("login")) if err != nil { handleDBError(c, err) return } } // TODO: disallow to change login, email, avatar if the user is using oauth user.Login = in.Login user.Email = in.Email user.Avatar = in.Avatar user.Admin = in.Admin err = _store.UpdateUser(user) if err != nil { c.AbortWithStatus(http.StatusConflict) return } c.JSON(http.StatusOK, user) } // PostUser // // @Summary Create a user // @Description Creates a new user account with the specified external login. Requires admin rights. // @Router /users [post] // @Produce json // @Success 200 {object} User // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param user body User true "the user's data" func PostUser(c *gin.Context) { in := &model.User{} err := c.Bind(in) if err != nil { c.String(http.StatusBadRequest, err.Error()) return } user := &model.User{ Login: in.Login, Email: in.Email, Avatar: in.Avatar, Hash: base32.StdEncoding.EncodeToString( random.GetRandomBytes(32), ), ForgeID: in.ForgeID, ForgeRemoteID: model.ForgeRemoteID("0"), // TODO: search for the user in the forge and get the remote id } if err = user.Validate(); err != nil { c.String(http.StatusBadRequest, err.Error()) return } if err = store.FromContext(c).CreateUser(user); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, user) } // DeleteUser // // @Summary Delete a user // @Description Deletes the given user. Requires admin rights. // @Router /users/{login} [delete] // @Produce plain // @Success 204 // @Tags Users // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param login path string true "the user's login name" // @Param forge_id query string true "specify forge (else default will be used)" // @Param forge_remote_id query string false "specify user id at forge (else fallback to login)" func DeleteUser(c *gin.Context) { _store := store.FromContext(c) forgeID, err := strconv.ParseInt(c.DefaultQuery("forge_id", fmt.Sprint(defaultForgeID)), 10, 64) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } forgeRemoteID := model.ForgeRemoteID(c.Query("forge_remote_id")) var user *model.User if forgeRemoteID.IsValid() { user, err = store.FromContext(c).GetUserByRemoteID(forgeID, forgeRemoteID) } else { user, err = store.FromContext(c).GetUserByLogin(forgeID, c.Param("login")) } if err != nil { handleDBError(c, err) return } if err = _store.DeleteUser(user); err != nil { handleDBError(c, err) return } c.Status(http.StatusNoContent) } ================================================ FILE: server/api/z.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "net/http" "github.com/gin-gonic/gin" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/version" ) // Health // // @Summary Health information // @Description If everything is fine, just a 204 will be returned, a 500 signals server state is unhealthy. // @Router /healthz [get] // @Produce plain // @Success 204 // @Failure 500 // @Tags System func Health(c *gin.Context) { if err := store.FromContext(c).Ping(); err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.Status(http.StatusNoContent) } // Version // // @Summary Get version // @Description Endpoint returns the server version and build information. // @Router /version [get] // @Produce json // @Success 200 {object} object{source=string,version=string} // @Tags System func Version(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "source": "https://github.com/woodpecker-ci/woodpecker", "version": version.String(), }) } // LogLevel // // @Summary Current log level // @Description Endpoint returns the current logging level. Requires admin rights. // @Router /log-level [get] // @Produce json // @Success 200 {object} object{log-level=string} // @Tags System func LogLevel(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "log-level": zerolog.GlobalLevel().String(), }) } // SetLogLevel // // @Summary Set log level // @Description Endpoint sets the current logging level. Requires admin rights. // @Router /log-level [post] // @Produce json // @Success 200 {object} object{log-level=string} // @Tags System // @Param Authorization header string true "Insert your personal access token" default(Bearer ) // @Param log-level body object{log-level=string} true "the new log level, one of " func SetLogLevel(c *gin.Context) { logLevel := struct { LogLevel string `json:"log-level"` }{} if err := c.Bind(&logLevel); err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } lvl, err := zerolog.ParseLevel(logLevel.LogLevel) if err != nil { _ = c.AbortWithError(http.StatusBadRequest, err) return } log.Log().Msgf("log level set to %s", lvl.String()) zerolog.SetGlobalLevel(lvl) c.JSON(http.StatusOK, logLevel) } ================================================ FILE: server/badges/badges.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package badges import ( "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var ( // Status labels. badgeStatusSuccess = "success" badgeStatusFailure = "failure" badgeStatusStarted = "started" badgeStatusError = "error" badgeStatusNone = "none" ) func getBadgeStatusLabelAndColor(status *model.StatusValue) (string, Color) { if status == nil { return badgeStatusNone, ColorGray } switch *status { case model.StatusSuccess: return badgeStatusSuccess, ColorGreen case model.StatusFailure: return badgeStatusFailure, ColorRed case model.StatusPending, model.StatusRunning: return badgeStatusStarted, ColorYellow case model.StatusError, model.StatusKilled: return badgeStatusError, ColorGray default: return badgeStatusNone, ColorGray } } // Generate an SVG badge based on a pipeline. func Generate(name string, status *model.StatusValue) (string, error) { label, color := getBadgeStatusLabelAndColor(status) bytes, err := RenderBytes(name, label, color) if err != nil { log.Warn().Err(err).Msg("could not render badge") return "", err } return string(bytes), nil } ================================================ FILE: server/badges/badges_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package badges import ( "bytes" "html/template" "strings" "sync" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var ( badgeNone = `pipelinepipelinenonenone` badgeSuccess = `pipelinepipelinesuccesssuccess` badgeFailure = `pipelinepipelinefailurefailure` badgeError = `pipelinepipelineerrorerror` badgeStarted = `pipelinepipelinestartedstarted` ) // Generate an SVG badge based on a pipeline. func TestGenerate(t *testing.T) { status := model.StatusDeclined badge, err := Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeNone, badge) status = model.StatusSuccess badge, err = Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeSuccess, badge) status = model.StatusFailure badge, err = Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeFailure, badge) status = model.StatusError badge, err = Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeError, badge) status = model.StatusKilled badge, err = Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeError, badge) status = model.StatusPending badge, err = Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeStarted, badge) status = model.StatusRunning badge, err = Generate("pipeline", &status) assert.NoError(t, err) assert.Equal(t, badgeStarted, badge) } func TestBadgeDrawerRender(t *testing.T) { mockTemplate := strings.TrimSpace(` {{.Subject}},{{.Status}},{{.Color}},{{with .Bounds}}{{.SubjectX}},{{.SubjectDx}},{{.StatusX}},{{.StatusDx}},{{.Dx}}{{end}} `) mockFontSize := 11.0 mockDPI := 72.0 fd, err := mustNewFontDrawer(mockFontSize, mockDPI) assert.NoError(t, err) d := &badgeDrawer{ fd: fd, tmpl: template.Must(template.New("mock-template").Parse(mockTemplate)), mutex: &sync.Mutex{}, } output := "XXX,YYY,#c0c0c0,15.5,29,41,26,55" var buf bytes.Buffer assert.NoError(t, d.Render("XXX", "YYY", "#c0c0c0", &buf)) assert.Equal(t, output, buf.String()) } func TestBadgeDrawerRenderBytes(t *testing.T) { mockTemplate := strings.TrimSpace(` {{.Subject}},{{.Status}},{{.Color}},{{with .Bounds}}{{.SubjectX}},{{.SubjectDx}},{{.StatusX}},{{.StatusDx}},{{.Dx}}{{end}} `) mockFontSize := 11.0 mockDPI := 72.0 fd, err := mustNewFontDrawer(mockFontSize, mockDPI) assert.NoError(t, err) d := &badgeDrawer{ fd: fd, tmpl: template.Must(template.New("mock-template").Parse(mockTemplate)), mutex: &sync.Mutex{}, } output := "XXX,YYY,#c0c0c0,15.5,29,41,26,55" bytes, err := d.RenderBytes("XXX", "YYY", "#c0c0c0") assert.NoError(t, err) assert.Equal(t, output, string(bytes)) } ================================================ FILE: server/badges/color.go ================================================ // Copyright 2023 The narqo/go-badge Authors. All rights reserved. // SPDX-License-Identifier: MIT. package badges // Color represents color of the badge. type Color string // Standard colors. const ( ColorGreen = "#44cc11" ColorYellow = "#dfb317" ColorRed = "#e05d44" ColorGray = "#9f9f9f" ) ================================================ FILE: server/badges/drawer.go ================================================ // Copyright 2023 The narqo/go-badge Authors. All rights reserved. // SPDX-License-Identifier: MIT. package badges // cspell:words Verdana import ( "bytes" "html/template" "io" "sync" "golang.org/x/image/font" "golang.org/x/image/font/opentype" "golang.org/x/image/font/sfnt" "go.woodpecker-ci.org/woodpecker/v3/server/badges/fonts" ) type badge struct { Subject string Status string Color Color Bounds bounds } type bounds struct { // SubjectDx is the width of subject string of the badge. SubjectDx float64 SubjectX float64 // StatusDx is the width of status string of the badge. StatusDx float64 StatusX float64 } func (b bounds) Dx() float64 { return b.SubjectDx + b.StatusDx } type badgeDrawer struct { fd *font.Drawer tmpl *template.Template mutex *sync.Mutex } func (d *badgeDrawer) Render(subject, status string, color Color, w io.Writer) error { d.mutex.Lock() subjectDx := d.measureString(subject) statusDx := d.measureString(status) d.mutex.Unlock() bdg := badge{ Subject: subject, Status: status, Color: color, Bounds: bounds{ SubjectDx: subjectDx, SubjectX: subjectDx/2.0 + 1, StatusDx: statusDx, StatusX: subjectDx + statusDx/2.0 - 1, }, } return d.tmpl.Execute(w, bdg) } func (d *badgeDrawer) RenderBytes(subject, status string, color Color) ([]byte, error) { buf := &bytes.Buffer{} err := d.Render(subject, status, color, buf) return buf.Bytes(), err } // shields.io uses Verdana.ttf to measure text width with an extra 10px. // As we use DejaVuSans.ttf, we have to tune this value a little. const extraDx = 5 func (d *badgeDrawer) measureString(s string) float64 { SHIFT := 6 return float64(d.fd.MeasureString(s)>>SHIFT) + extraDx } // RenderBytes renders a badge of the given color, with given subject and status to bytes. func RenderBytes(subject, status string, color Color) ([]byte, error) { drawer, err := initDrawer() if err != nil { return nil, err } return drawer.RenderBytes(subject, status, color) } const ( dpi = 72 fontSize = 11 ) var ( drawer *badgeDrawer initError error initOnce sync.Once ) func initDrawer() (*badgeDrawer, error) { initOnce.Do(func() { fd, err := mustNewFontDrawer(fontSize, dpi) if err != nil { initError = err return } drawer = &badgeDrawer{ fd: fd, tmpl: template.Must(template.New("flat-template").Parse(flatTemplate)), mutex: &sync.Mutex{}, } initError = nil }) return drawer, initError } func mustNewFontDrawer(size, dpi float64) (*font.Drawer, error) { f, err := sfnt.Parse(fonts.DejaVuSans) if err != nil { return nil, err } face, err := opentype.NewFace(f, &opentype.FaceOptions{ Size: size, DPI: dpi, Hinting: font.HintingFull, }) if err != nil { return nil, err } return &font.Drawer{ Face: face, }, nil } ================================================ FILE: server/badges/fonts/dejavusans.go ================================================ // Copyright 2023 The narqo/go-badge Authors. All rights reserved. // SPDX-License-Identifier: MIT. package fonts import ( _ "embed" ) // DejaVuSans is DejaVuSans.ttf font inlined to the bytes slice. // //go:embed DejaVuSans.ttf var DejaVuSans []byte ================================================ FILE: server/badges/style.go ================================================ // Copyright 2023 The narqo/go-badge Authors. All rights reserved. // SPDX-License-Identifier: MIT. package badges // cspell:words Verdana var flatTemplate = `{{.Subject | html}}{{.Subject | html}}{{.Status | html}}{{.Status | html}}` ================================================ FILE: server/cache/membership.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cache import ( "context" "errors" "fmt" "time" "github.com/jellydator/ttlcache/v3" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // MembershipService is a service to check for user membership. type MembershipService interface { // Get returns if the user is a member of the organization. Get(ctx context.Context, _forge forge.Forge, u *model.User, org string) (*model.OrgPerm, error) } type membershipCache struct { cache *ttlcache.Cache[string, *model.OrgPerm] store store.Store ttl time.Duration } // NewMembershipService creates a new membership service. func NewMembershipService(_store store.Store) MembershipService { return &membershipCache{ ttl: 10 * time.Minute, //nolint:mnd store: _store, cache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, *model.OrgPerm]()), } } // Get returns if the user is a member of the organization. func (c *membershipCache) Get(ctx context.Context, _forge forge.Forge, u *model.User, org string) (*model.OrgPerm, error) { key := fmt.Sprintf("%s-%s", u.ForgeRemoteID, org) item := c.cache.Get(key) if item != nil && !item.IsExpired() { return item.Value(), nil } perm, err := _forge.OrgMembership(ctx, u, org) if errors.Is(err, forge_types.ErrNotImplemented) { log.Debug().Msg("Could not check user org membership as forge adapter did not implement it") return &model.OrgPerm{}, nil } else if err != nil { return nil, err } c.cache.Set(key, perm, c.ttl) return perm, nil } ================================================ FILE: server/ccmenu/cc.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ccmenu import ( "encoding/xml" "strconv" "time" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // CCMenu displays the pipeline status of projects on a ci server as an item in the Mac's menu bar. // It started as part of the CruiseControl project that built the first continuous integration server. // // http://ccmenu.org/ type CCProjects struct { XMLName xml.Name `xml:"Projects"` Project *CCProject `xml:"Project"` } type CCProject struct { XMLName xml.Name `xml:"Project"` Name string `xml:"name,attr"` Activity string `xml:"activity,attr"` LastBuildStatus string `xml:"lastBuildStatus,attr"` LastBuildLabel string `xml:"lastBuildLabel,attr"` LastBuildTime string `xml:"lastBuildTime,attr"` WebURL string `xml:"webUrl,attr"` } func New(r *model.Repo, b *model.Pipeline, url string) *CCProjects { proj := &CCProject{ Name: r.FullName, WebURL: url, Activity: "Building", LastBuildStatus: "Unknown", LastBuildLabel: "Unknown", } // if the pipeline is not currently running then // we can return the latest pipeline status. if b.Status != model.StatusPending && b.Status != model.StatusRunning { proj.Activity = "Sleeping" proj.LastBuildTime = time.Unix(b.Started, 0).Format(time.RFC3339) proj.LastBuildLabel = strconv.FormatInt(b.Number, 10) } // ensure the last pipeline status accepts a valid // ccmenu enumeration switch b.Status { case model.StatusError, model.StatusKilled: proj.LastBuildStatus = "Exception" case model.StatusSuccess: proj.LastBuildStatus = "Success" case model.StatusFailure: proj.LastBuildStatus = "Failure" } return &CCProjects{Project: proj} } ================================================ FILE: server/ccmenu/cc_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ccmenu import ( "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestCC(t *testing.T) { t.Run("create a project", func(t *testing.T) { now := time.Now().Unix() nowFmt := time.Unix(now, 0).Format(time.RFC3339) r := &model.Repo{ FullName: "foo/bar", } b := &model.Pipeline{ Status: model.StatusSuccess, Number: 1, Started: now, } cc := New(r, b, "http://localhost/foo/bar/1") assert.Equal(t, "foo/bar", cc.Project.Name) assert.Equal(t, "Sleeping", cc.Project.Activity) assert.Equal(t, "Success", cc.Project.LastBuildStatus) assert.Equal(t, "1", cc.Project.LastBuildLabel) assert.Equal(t, nowFmt, cc.Project.LastBuildTime) assert.Equal(t, "http://localhost/foo/bar/1", cc.Project.WebURL) }) t.Run("properly label exceptions", func(t *testing.T) { r := &model.Repo{FullName: "foo/bar"} b := &model.Pipeline{ Status: model.StatusError, Number: 1, Started: 1257894000, } cc := New(r, b, "http://localhost/foo/bar/1") assert.Equal(t, "Exception", cc.Project.LastBuildStatus) assert.Equal(t, "Sleeping", cc.Project.Activity) }) t.Run("properly label success", func(t *testing.T) { r := &model.Repo{FullName: "foo/bar"} b := &model.Pipeline{ Status: model.StatusSuccess, Number: 1, Started: 1257894000, } cc := New(r, b, "http://localhost/foo/bar/1") assert.Equal(t, "Success", cc.Project.LastBuildStatus) assert.Equal(t, "Sleeping", cc.Project.Activity) }) t.Run("properly label failure", func(t *testing.T) { r := &model.Repo{FullName: "foo/bar"} b := &model.Pipeline{ Status: model.StatusFailure, Number: 1, Started: 1257894000, } cc := New(r, b, "http://localhost/foo/bar/1") assert.Equal(t, "Failure", cc.Project.LastBuildStatus) assert.Equal(t, "Sleeping", cc.Project.Activity) }) t.Run("properly label running", func(t *testing.T) { r := &model.Repo{FullName: "foo/bar"} b := &model.Pipeline{ Status: model.StatusRunning, Number: 1, Started: 1257894000, } cc := New(r, b, "http://localhost/foo/bar/1") assert.Equal(t, "Building", cc.Project.Activity) assert.Equal(t, "Unknown", cc.Project.LastBuildStatus) assert.Equal(t, "Unknown", cc.Project.LastBuildLabel) }) } ================================================ FILE: server/config.go ================================================ // Copyright 2018 Drone.IO Inc. // Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package server import ( "time" "go.woodpecker-ci.org/woodpecker/v3/server/cache" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" "go.woodpecker-ci.org/woodpecker/v3/server/services" "go.woodpecker-ci.org/woodpecker/v3/server/services/log" "go.woodpecker-ci.org/woodpecker/v3/server/services/permissions" ) var Config = struct { Services struct { Scheduler scheduler.Scheduler Logs logging.Log Membership cache.MembershipService Manager services.Manager LogStore log.Service } Server struct { JWTSecret string Key string Cert string OAuthHost string Host string WebhookHost string Port string PortTLS string AgentToken string StatusContext string StatusContextFormat string SessionExpires time.Duration RootPath string CustomCSSFile string CustomJsFile string } Agent struct { DisableUserRegisteredAgentRegistration bool } WebUI struct { EnableSwagger bool SkipVersionCheck bool MaxPipelineLogLineCount uint } Prometheus struct { AuthToken string } Pipeline struct { AuthenticatePublicRepos bool DefaultAllowPullRequests bool DefaultCancelPreviousPipelineEvents []model.WebhookEvent DefaultApprovalMode model.ApprovalMode DefaultWorkflowLabels map[string]string DefaultClonePlugin string TrustedClonePlugins []string Volumes []string Networks []string PrivilegedPlugins []string DefaultTimeout int64 MaxTimeout int64 Proxy struct { No string HTTP string HTTPS string } // TODO: remove with version 4.x ForceIgnoreServiceFailure bool } Permissions struct { Open bool Admins *permissions.Admins Orgs *permissions.Orgs OwnersAllowlist *permissions.OwnersAllowlist } }{} ================================================ FILE: server/cron/cron.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "context" "fmt" "time" "github.com/gdgvda/cron" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) const ( // Specifies the interval woodpecker checks for new crons to exec. checkTime = time.Minute // Specifies the batch size of crons to retrieve per check from database. checkItems = 10 ) // Run starts the cron scheduler loop. func Run(ctx context.Context, store store.Store) error { for { select { case <-ctx.Done(): return nil case <-time.After(checkTime): go func() { now := time.Now() log.Trace().Msg("cron: fetch next crons") crons, err := store.CronListNextExecute(now.Unix(), checkItems) if err != nil { log.Error().Err(err).Int64("now", now.Unix()).Msg("obtain cron list") return } for _, cron := range crons { if err := runCron(ctx, store, cron, now); err != nil { log.Error().Err(err).Int64("cronID", cron.ID).Msg("run cron failed") } } }() } } } // CalcNewNext parses a cron string and calculates the next exec time based on it. func CalcNewNext(schedule string, now time.Time) (time.Time, error) { // remove local timezone now = now.UTC() // TODO: allow the users / the admin to set a specific timezone parser, err := cron.NewDefaultParser(cron.StandardOptions) if err != nil { return time.Time{}, fmt.Errorf("can't create parser: %w", err) } c, err := parser.Parse(schedule) if err != nil { return time.Time{}, fmt.Errorf("cron parse schedule: %w", err) } return c.Next(now), nil } func runCron(ctx context.Context, store store.Store, cron *model.Cron, now time.Time) error { log.Trace().Msgf("cron: run id[%d]", cron.ID) newNext, err := CalcNewNext(cron.Schedule, now) if err != nil { return err } // try to get lock on cron gotLock, err := store.CronGetLock(cron, newNext.Unix()) if err != nil { return err } if !gotLock { // another go routine caught it return nil } repo, newPipeline, err := CreatePipeline(ctx, store, cron) if err != nil { return err } _, err = pipeline.Create(ctx, store, repo, newPipeline) return err } func CreatePipeline(ctx context.Context, store store.Store, cron *model.Cron) (*model.Repo, *model.Pipeline, error) { repo, err := store.GetRepo(cron.RepoID) if err != nil { return nil, nil, err } _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { return nil, nil, err } if cron.Branch == "" { // fallback to the repos default branch cron.Branch = repo.Branch } repoUser, err := store.GetUser(repo.UserID) if err != nil { return nil, nil, err } // If the forge has a refresh token, the current access token // may be stale. Therefore, we should refresh prior to dispatching // the pipeline. forge.Refresh(ctx, _forge, store, repoUser) commit, err := _forge.BranchHead(ctx, repoUser, repo, cron.Branch) if err != nil { return nil, nil, err } return repo, &model.Pipeline{ Event: model.EventCron, Commit: commit.SHA, Ref: "refs/heads/" + cron.Branch, Branch: cron.Branch, Timestamp: cron.NextExec, Cron: cron.Name, ForgeURL: commit.ForgeURL, AdditionalVariables: cron.Variables, }, nil } ================================================ FILE: server/cron/cron_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cron import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/model" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestCreatePipeline(t *testing.T) { _manager := manager_mocks.NewMockManager(t) _forge := forge_mocks.NewMockForge(t) store := store_mocks.NewMockStore(t) ctx := t.Context() repoUser := &model.User{ ID: 1, Login: "user1", } repo1 := &model.Repo{ ID: 1, Name: "repo1", Owner: "owner1", FullName: "repo1/owner1", Branch: "default", UserID: repoUser.ID, } // mock things store.On("GetRepo", mock.Anything).Return(repo1, nil) store.On("GetUser", mock.Anything).Return(repoUser, nil) _forge.On("BranchHead", mock.Anything, repoUser, repo1, "default").Return(&model.Commit{ ForgeURL: "https://example.com/sha1", SHA: "sha1", }, nil) _manager.On("ForgeFromRepo", repo1).Return(_forge, nil) server.Config.Services.Manager = _manager _, pipeline, err := CreatePipeline(ctx, store, &model.Cron{ Name: "test", }) assert.NoError(t, err) assert.EqualValues(t, &model.Pipeline{ Branch: "default", Commit: "sha1", Event: "cron", ForgeURL: "https://example.com/sha1", Ref: "refs/heads/default", Cron: "test", }, pipeline) } func TestCalcNewNext(t *testing.T) { now := time.Unix(1661962369, 0) _, err := CalcNewNext("", now) assert.Error(t, err) schedule, err := CalcNewNext("@every 5m", now) assert.NoError(t, err) assert.EqualValues(t, 1661962669, schedule.Unix()) } ================================================ FILE: server/forge/addon/args.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type argumentsRepo struct { U *modelUser `json:"u"` RemoteID model.ForgeRemoteID `json:"remote_id"` Owner string `json:"owner"` Name string `json:"name"` } type argumentsFileDir struct { U *modelUser `json:"u"` R *modelRepo `json:"r"` B *model.Pipeline `json:"b"` F string `json:"f"` } type argumentsStatus struct { U *modelUser `json:"u"` R *modelRepo `json:"r"` B *model.Pipeline `json:"b"` P *model.Workflow `json:"p"` } type argumentsNetrc struct { U *modelUser `json:"u"` R *modelRepo `json:"r"` } type argumentsActivateDeactivate struct { U *modelUser `json:"u"` R *modelRepo `json:"r"` Link string `json:"link"` } type argumentsBranchesPullRequests struct { U *modelUser `json:"u"` R *modelRepo `json:"r"` P *model.ListOptions `json:"p"` } type argumentsBranchHead struct { U *modelUser `json:"u"` R *modelRepo `json:"r"` Branch string `json:"branch"` } type argumentsOrgMembershipOrg struct { U *modelUser `json:"u"` Org string `json:"org"` } type argumentsTeams struct { U *modelUser `json:"u"` P *model.ListOptions `json:"p"` } type argumentsRepos struct { U *modelUser `json:"u"` P *model.ListOptions `json:"p"` } type responseHook struct { Repo *modelRepo `json:"repo"` Pipeline *model.Pipeline `json:"pipeline"` } type responseLogin struct { User *modelUser `json:"user"` RedirectURL string `json:"redirect_url"` } type httpRequest struct { Method string `json:"method"` URL string `json:"url"` Header map[string][]string `json:"header"` Form map[string][]string `json:"form"` Body []byte `json:"body"` } // modelUser is an extension of model.User to marshal all fields to JSON. type modelUser struct { User *model.User `json:"user"` ForgeRemoteID model.ForgeRemoteID `json:"forge_remote_id"` // Token is the oauth2 token. Token string `json:"token"` // Secret is the oauth2 token secret. Secret string `json:"secret"` // Expiry is the token and secret expiration timestamp. Expiry int64 `json:"expiry"` // Hash is a unique token used to sign tokens. Hash string `json:"hash"` } func (m *modelUser) asModel() *model.User { if m == nil { return nil } m.User.ForgeRemoteID = m.ForgeRemoteID m.User.AccessToken = m.Token m.User.RefreshToken = m.Secret m.User.Expiry = m.Expiry m.User.Hash = m.Hash return m.User } func modelUserFromModel(u *model.User) *modelUser { if u == nil { return nil } return &modelUser{ User: u, ForgeRemoteID: u.ForgeRemoteID, Token: u.AccessToken, Secret: u.RefreshToken, Expiry: u.Expiry, Hash: u.Hash, } } // modelRepo is an extension of model.Repo to marshal all fields to JSON. type modelRepo struct { Repo *model.Repo `json:"repo"` UserID int64 `json:"user_id"` Hash string `json:"hash"` Perm *model.Perm `json:"perm"` } func (m *modelRepo) asModel() *model.Repo { if m == nil { return nil } m.Repo.UserID = m.UserID m.Repo.Hash = m.Hash m.Repo.Perm = m.Perm return m.Repo } func modelRepoFromModel(r *model.Repo) *modelRepo { if r == nil { return nil } return &modelRepo{ Repo: r, UserID: r.UserID, Hash: r.Hash, Perm: r.Perm, } } ================================================ FILE: server/forge/addon/client.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "context" "encoding/json" "io" "net/http" "net/rpc" "os/exec" "github.com/hashicorp/go-plugin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" ) // make sure RPC implements forge.Forge. var _ forge.Forge = new(RPC) func Load(file string) (forge.Forge, error) { client := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: HandshakeConfig, Plugins: map[string]plugin.Plugin{ pluginKey: &Plugin{}, }, Cmd: exec.Command(file), Logger: &logger.AddonClientLogger{ Logger: log.With().Str("addon", file).Logger(), }, }) // TODO: defer client.Kill() rpcClient, err := client.Client() if err != nil { return nil, err } raw, err := rpcClient.Dispense(pluginKey) if err != nil { return nil, err } extension, _ := raw.(forge.Forge) return extension, nil } type RPC struct { client *rpc.Client } func (g *RPC) Name() string { var resp string err := g.client.Call("Plugin.Name", []byte{}, &resp) if err != nil { log.Error().Err(err).Msg("addon Plugin.Name call failed") } return resp } func (g *RPC) URL() string { var resp string err := g.client.Call("Plugin.URL", []byte{}, &resp) if err != nil { log.Error().Err(err).Msg("addon Plugin.URL call failed") } return resp } func (g *RPC) Login(_ context.Context, r *types.OAuthRequest) (*model.User, string, error) { args, err := json.Marshal(r) if err != nil { return nil, "", err } var jsonResp []byte err = g.client.Call("Plugin.Login", args, &jsonResp) if err != nil { return nil, "", err } var resp responseLogin err = json.Unmarshal(jsonResp, &resp) if err != nil { return nil, "", err } return resp.User.asModel(), resp.RedirectURL, nil } func (g *RPC) Teams(_ context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { args, err := json.Marshal(&argumentsTeams{ U: modelUserFromModel(u), P: p, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Teams", args, &jsonResp) if err != nil { return nil, err } var resp []*model.Team return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) Repo(_ context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { args, err := json.Marshal(&argumentsRepo{ U: modelUserFromModel(u), RemoteID: remoteID, Owner: owner, Name: name, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Repo", args, &jsonResp) if err != nil { return nil, err } var resp modelRepo err = json.Unmarshal(jsonResp, &resp) if err != nil { return nil, err } return resp.asModel(), nil } func (g *RPC) Repos(_ context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { args, err := json.Marshal(&argumentsRepos{ U: modelUserFromModel(u), P: p, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Repos", args, &jsonResp) if err != nil { return nil, err } var resp []*modelRepo err = json.Unmarshal(jsonResp, &resp) if err != nil { return nil, err } var modelRepos []*model.Repo for _, repo := range resp { modelRepos = append(modelRepos, repo.asModel()) } return modelRepos, nil } func (g *RPC) File(_ context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) { args, err := json.Marshal(&argumentsFileDir{ U: modelUserFromModel(u), R: modelRepoFromModel(r), B: b, F: f, }) if err != nil { return nil, err } var resp []byte return resp, g.client.Call("Plugin.File", args, &resp) } func (g *RPC) Dir(_ context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*types.FileMeta, error) { args, err := json.Marshal(&argumentsFileDir{ U: modelUserFromModel(u), R: modelRepoFromModel(r), B: b, F: f, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Dir", args, &jsonResp) if err != nil { return nil, err } var resp []*types.FileMeta return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) Status(_ context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error { args, err := json.Marshal(&argumentsStatus{ U: modelUserFromModel(u), R: modelRepoFromModel(r), B: b, P: p, }) if err != nil { return err } var jsonResp []byte return g.client.Call("Plugin.Status", args, &jsonResp) } func (g *RPC) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { args, err := json.Marshal(&argumentsNetrc{ U: modelUserFromModel(u), R: modelRepoFromModel(r), }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Netrc", args, &jsonResp) if err != nil { return nil, err } var resp *model.Netrc return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) Activate(_ context.Context, u *model.User, r *model.Repo, link string) error { args, err := json.Marshal(&argumentsActivateDeactivate{ U: modelUserFromModel(u), R: modelRepoFromModel(r), Link: link, }) if err != nil { return err } var jsonResp []byte return g.client.Call("Plugin.Activate", args, &jsonResp) } func (g *RPC) Deactivate(_ context.Context, u *model.User, r *model.Repo, link string) error { args, err := json.Marshal(&argumentsActivateDeactivate{ U: modelUserFromModel(u), R: modelRepoFromModel(r), Link: link, }) if err != nil { return err } var jsonResp []byte return g.client.Call("Plugin.Deactivate", args, &jsonResp) } func (g *RPC) Branches(_ context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { args, err := json.Marshal(&argumentsBranchesPullRequests{ U: modelUserFromModel(u), R: modelRepoFromModel(r), P: p, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Branches", args, &jsonResp) if err != nil { return nil, err } var resp []string return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) BranchHead(_ context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { args, err := json.Marshal(&argumentsBranchHead{ U: modelUserFromModel(u), R: modelRepoFromModel(r), Branch: branch, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.BranchHead", args, &jsonResp) if err != nil { return nil, err } var resp *model.Commit return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) PullRequests(_ context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { args, err := json.Marshal(&argumentsBranchesPullRequests{ U: modelUserFromModel(u), R: modelRepoFromModel(r), P: p, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.PullRequests", args, &jsonResp) if err != nil { return nil, err } var resp []*model.PullRequest return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) Hook(_ context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { body, err := io.ReadAll(r.Body) if err != nil { return nil, nil, err } args, err := json.Marshal(&httpRequest{ Method: r.Method, URL: r.URL.String(), Header: r.Header, Form: r.Form, Body: body, }) if err != nil { return nil, nil, err } var jsonResp []byte err = g.client.Call("Plugin.Hook", args, &jsonResp) if err != nil { return nil, nil, err } var resp responseHook err = json.Unmarshal(jsonResp, &resp) if err != nil { return nil, nil, err } return resp.Repo.asModel(), resp.Pipeline, nil } func (g *RPC) OrgMembership(_ context.Context, u *model.User, org string) (*model.OrgPerm, error) { args, err := json.Marshal(&argumentsOrgMembershipOrg{ U: modelUserFromModel(u), Org: org, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.OrgMembership", args, &jsonResp) if err != nil { return nil, err } var resp *model.OrgPerm return resp, json.Unmarshal(jsonResp, &resp) } func (g *RPC) Org(_ context.Context, u *model.User, org string) (*model.Org, error) { args, err := json.Marshal(&argumentsOrgMembershipOrg{ U: modelUserFromModel(u), Org: org, }) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.Org", args, &jsonResp) if err != nil { return nil, err } var resp *model.Org return resp, json.Unmarshal(jsonResp, &resp) } ================================================ FILE: server/forge/addon/plugin.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "net/rpc" "github.com/hashicorp/go-plugin" "go.woodpecker-ci.org/woodpecker/v3/server/forge" ) const pluginKey = "forge" var HandshakeConfig = plugin.HandshakeConfig{ ProtocolVersion: 1, MagicCookieKey: "WOODPECKER_FORGE_ADDON_PLUGIN", MagicCookieValue: "woodpecker-plugin-magic-cookie-value", } type Plugin struct { Impl forge.Forge } func (p *Plugin) Server(*plugin.MuxBroker) (any, error) { return &RPCServer{Impl: p.Impl}, nil } func (*Plugin) Client(_ *plugin.MuxBroker, c *rpc.Client) (any, error) { return &RPC{client: c}, nil } ================================================ FILE: server/forge/addon/server.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "bytes" "context" "encoding/json" "net/http" "github.com/hashicorp/go-plugin" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" ) func Serve(impl forge.Forge) { plugin.Serve(&plugin.ServeConfig{ HandshakeConfig: HandshakeConfig, Plugins: map[string]plugin.Plugin{ pluginKey: &Plugin{Impl: impl}, }, }) } func mkCtx() context.Context { return context.Background() } type RPCServer struct { Impl forge.Forge } func (s *RPCServer) Name(_ []byte, resp *string) error { *resp = s.Impl.Name() return nil } func (s *RPCServer) URL(_ []byte, resp *string) error { *resp = s.Impl.URL() return nil } func (s *RPCServer) Teams(args []byte, resp *[]byte) error { var a argumentsTeams err := json.Unmarshal(args, &a) if err != nil { return err } teams, err := s.Impl.Teams(mkCtx(), a.U.asModel(), a.P) if err != nil { return err } *resp, err = json.Marshal(teams) return err } func (s *RPCServer) Repo(args []byte, resp *[]byte) error { var a argumentsRepo err := json.Unmarshal(args, &a) if err != nil { return err } repos, err := s.Impl.Repo(mkCtx(), a.U.asModel(), a.RemoteID, a.Owner, a.Name) if err != nil { return err } *resp, err = json.Marshal(modelRepoFromModel(repos)) return err } func (s *RPCServer) Repos(args []byte, resp *[]byte) error { var a argumentsRepos err := json.Unmarshal(args, &a) if err != nil { return err } repos, err := s.Impl.Repos(mkCtx(), a.U.asModel(), a.P) if err != nil { return err } var modelRepos []*modelRepo for _, repo := range repos { modelRepos = append(modelRepos, modelRepoFromModel(repo)) } *resp, err = json.Marshal(modelRepos) return err } func (s *RPCServer) File(args []byte, resp *[]byte) error { var a argumentsFileDir err := json.Unmarshal(args, &a) if err != nil { return err } *resp, err = s.Impl.File(mkCtx(), a.U.asModel(), a.R.asModel(), a.B, a.F) return err } func (s *RPCServer) Dir(args []byte, resp *[]byte) error { var a argumentsFileDir err := json.Unmarshal(args, &a) if err != nil { return err } meta, err := s.Impl.Dir(mkCtx(), a.U.asModel(), a.R.asModel(), a.B, a.F) if err != nil { return err } *resp, err = json.Marshal(meta) return err } func (s *RPCServer) Status(args []byte, resp *[]byte) error { var a argumentsStatus err := json.Unmarshal(args, &a) if err != nil { return err } *resp = []byte{} return s.Impl.Status(mkCtx(), a.U.asModel(), a.R.asModel(), a.B, a.P) } func (s *RPCServer) Netrc(args []byte, resp *[]byte) error { var a argumentsNetrc err := json.Unmarshal(args, &a) if err != nil { return err } netrc, err := s.Impl.Netrc(a.U.asModel(), a.R.asModel()) if err != nil { return err } *resp, err = json.Marshal(netrc) return err } func (s *RPCServer) Activate(args []byte, resp *[]byte) error { var a argumentsActivateDeactivate err := json.Unmarshal(args, &a) if err != nil { return err } *resp = []byte{} return s.Impl.Activate(mkCtx(), a.U.asModel(), a.R.asModel(), a.Link) } func (s *RPCServer) Deactivate(args []byte, resp *[]byte) error { var a argumentsActivateDeactivate err := json.Unmarshal(args, &a) if err != nil { return err } *resp = []byte{} return s.Impl.Deactivate(mkCtx(), a.U.asModel(), a.R.asModel(), a.Link) } func (s *RPCServer) Branches(args []byte, resp *[]byte) error { var a argumentsBranchesPullRequests err := json.Unmarshal(args, &a) if err != nil { return err } branches, err := s.Impl.Branches(mkCtx(), a.U.asModel(), a.R.asModel(), a.P) if err != nil { return err } *resp, err = json.Marshal(branches) return err } func (s *RPCServer) BranchHead(args []byte, resp *[]byte) error { var a argumentsBranchHead err := json.Unmarshal(args, &a) if err != nil { return err } commit, err := s.Impl.BranchHead(mkCtx(), a.U.asModel(), a.R.asModel(), a.Branch) if err != nil { return err } *resp, err = json.Marshal(commit) return err } func (s *RPCServer) PullRequests(args []byte, resp *[]byte) error { var a argumentsBranchesPullRequests err := json.Unmarshal(args, &a) if err != nil { return err } prs, err := s.Impl.PullRequests(mkCtx(), a.U.asModel(), a.R.asModel(), a.P) if err != nil { return err } *resp, err = json.Marshal(prs) return err } func (s *RPCServer) OrgMembership(args []byte, resp *[]byte) error { var a argumentsOrgMembershipOrg err := json.Unmarshal(args, &a) if err != nil { return err } org, err := s.Impl.OrgMembership(mkCtx(), a.U.asModel(), a.Org) if err != nil { return err } *resp, err = json.Marshal(org) return err } func (s *RPCServer) Org(args []byte, resp *[]byte) error { var a argumentsOrgMembershipOrg err := json.Unmarshal(args, &a) if err != nil { return err } org, err := s.Impl.Org(mkCtx(), a.U.asModel(), a.Org) if err != nil { return err } *resp, err = json.Marshal(org) return err } func (s *RPCServer) Hook(args []byte, resp *[]byte) error { var a httpRequest err := json.Unmarshal(args, &a) if err != nil { return err } req, err := http.NewRequest(a.Method, a.URL, bytes.NewBuffer(a.Body)) if err != nil { return err } req.Header = a.Header req.Form = a.Form repo, pipeline, err := s.Impl.Hook(mkCtx(), req) if err != nil { return err } *resp, err = json.Marshal(&responseHook{ Repo: modelRepoFromModel(repo), Pipeline: pipeline, }) return err } func (s *RPCServer) Login(args []byte, resp *[]byte) error { var a types.OAuthRequest err := json.Unmarshal(args, &a) if err != nil { return err } user, red, err := s.Impl.Login(mkCtx(), &a) if err != nil { return err } *resp, err = json.Marshal(&responseLogin{ User: modelUserFromModel(user), RedirectURL: red, }) return err } ================================================ FILE: server/forge/bitbucket/bitbucket.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucket import ( "context" "errors" "fmt" "net/http" "net/url" "path/filepath" "strconv" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) // Bitbucket cloud endpoints. const ( DefaultAPI = "https://api.bitbucket.org" DefaultURL = "https://bitbucket.org" pageSize = 100 ) // Opts are forge options for bitbucket. type Opts struct { OAuthClientID string OAuthClientSecret string } type config struct { forgeID int64 api string url string oAuthClientID string oAuthSecret string } // New returns a new forge Configuration for integrating with the Bitbucket // repository hosting service at https://bitbucket.org func New(id int64, opts *Opts) (forge.Forge, error) { return &config{ forgeID: id, api: DefaultAPI, url: DefaultURL, oAuthClientID: opts.OAuthClientID, oAuthSecret: opts.OAuthClientSecret, }, nil // TODO: add checks } // Name returns the string name of this driver. func (c *config) Name() string { return "bitbucket" } // URL returns the root url of a configured forge. func (c *config) URL() string { return c.url } // Login authenticates an account with Bitbucket using the oauth2 protocol. The // Bitbucket account details are returned when the user is successfully authenticated. func (c *config) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { config := c.newOAuth2Config() redirectURL := config.AuthCodeURL(req.State) // check the OAuth code if len(req.Code) == 0 { return nil, redirectURL, nil } token, err := config.Exchange(ctx, req.Code) if err != nil { return nil, redirectURL, err } client := internal.NewClient(ctx, c.api, config.Client(ctx, token)) curr, err := client.FindCurrent() if err != nil { return nil, redirectURL, err } emails, err := client.ListEmail() if err != nil { return nil, redirectURL, err } primaryEmail := "" for _, e := range emails.Values { if e.IsPrimary { primaryEmail = e.Email break } } return convertUser(curr, token, primaryEmail), redirectURL, nil } // Refresh refreshes the Bitbucket oauth2 access token. If the token is // refreshed the user is updated and a true value is returned. func (c *config) Refresh(ctx context.Context, user *model.User) (bool, error) { config := c.newOAuth2Config() source := config.TokenSource( ctx, &oauth2.Token{RefreshToken: user.RefreshToken}) token, err := source.Token() if err != nil || len(token.AccessToken) == 0 { return false, err } user.AccessToken = token.AccessToken user.RefreshToken = token.RefreshToken user.Expiry = token.Expiry.UTC().Unix() return true, nil } // Teams returns a list of all team membership for the Bitbucket account. func (c *config) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { setListOptions(p) opts := &internal.ListOpts{ PageLen: p.PerPage, Page: p.Page, } resp, err := c.newClient(ctx, u).ListWorkspaces(opts) if err != nil { return nil, err } var workspaces []*internal.Workspace for _, access := range resp.Values { if access.Workspace != nil { workspaces = append(workspaces, access.Workspace) } } return convertWorkspaceList(workspaces), nil } // Repo returns the named Bitbucket repository. func (c *config) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { if remoteID.IsValid() { name = string(remoteID) } if owner == "" { repos, err := c.Repos(ctx, u, &model.ListOptions{Page: 1}) if err != nil { return nil, err } for _, repo := range repos { if string(repo.ForgeRemoteID) == name { owner = repo.Owner break } } } client := c.newClient(ctx, u) repo, err := client.FindRepo(owner, name) if err != nil { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } perm, err := client.GetPermission(owner, repo.FullName) if err != nil { return nil, err } return convertRepo(repo, perm), nil } // Repos returns a list of all repositories for Bitbucket account, including // organization repositories. func (c *config) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { // we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667) // we merge data from different sources if p.Page != 1 { return nil, nil } setListOptions(p) client := c.newClient(ctx, u) resp, err := client.ListWorkspaces(&internal.ListOpts{ Page: p.Page, PageLen: p.PerPage, }) if err != nil { return nil, err } var all []*model.Repo for _, access := range resp.Values { if access.Workspace == nil { continue } workspace := access.Workspace repos, err := client.ListReposAll(workspace.Slug) if err != nil { return nil, err } userPermissions, err := client.ListPermissionsAll(workspace.Slug) if err != nil { return nil, err } userPermissionsByRepo := make(map[string]*internal.RepoPerm) for _, permission := range userPermissions { userPermissionsByRepo[permission.Repo.FullName] = permission } for _, repo := range repos { if perm, ok := userPermissionsByRepo[repo.FullName]; ok { all = append(all, convertRepo(repo, perm)) } } } return all, nil } // File fetches the file from the Bitbucket repository and returns its contents. func (c *config) File(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]byte, error) { config, err := c.newClient(ctx, u).FindSource(r.Owner, r.Name, p.Commit, f) if err != nil { var rspErr internal.Error if ok := errors.As(err, &rspErr); ok && rspErr.Status == http.StatusNotFound { return nil, &forge_types.ErrConfigNotFound{ Configs: []string{f}, } } return nil, err } return []byte(*config), nil } // Dir fetches a folder from the bitbucket repository. func (c *config) Dir(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]*forge_types.FileMeta, error) { var page *string repoPathFiles := []*forge_types.FileMeta{} client := c.newClient(ctx, u) for { filesResp, err := client.GetRepoFiles(r.Owner, r.Name, p.Commit, f, page) if err != nil { var rspErr internal.Error if ok := errors.As(err, &rspErr); ok && rspErr.Status == http.StatusNotFound { return nil, &forge_types.ErrConfigNotFound{ Configs: []string{f}, } } return nil, err } for _, file := range filesResp.Values { _, filename := filepath.Split(file.Path) repoFile := forge_types.FileMeta{ Name: filename, } if file.Type == "commit_file" { fileData, err := c.newClient(ctx, u).FindSource(r.Owner, r.Name, p.Commit, file.Path) if err != nil { return nil, err } if fileData != nil { repoFile.Data = []byte(*fileData) } } repoPathFiles = append(repoPathFiles, &repoFile) } // Check for more results page if filesResp.Next == nil { break } nextPageURL, err := url.Parse(*filesResp.Next) if err != nil { return nil, err } params, err := url.ParseQuery(nextPageURL.RawQuery) if err != nil { return nil, err } nextPage := params.Get("page") if len(nextPage) == 0 { break } page = &nextPage } return repoPathFiles, nil } // Status creates a pipeline status for the Bitbucket commit. func (c *config) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { status := internal.PipelineStatus{ State: convertStatus(workflow.State), Desc: common.GetPipelineStatusDescription(workflow.State), Key: common.GetPipelineStatusContext(repo, pipeline, workflow), URL: common.GetPipelineStatusURL(repo, pipeline, workflow), } if pipeline.Event == model.EventPush || pipeline.IsPullRequest() { status.Refname = pipeline.Branch } return c.newClient(ctx, user).CreateStatus(repo.Owner, repo.Name, pipeline.Commit, &status) } // Activate activates the repository by registering repository push hooks with // the Bitbucket repository. Prior to registering hook, previously created hooks // are deleted. func (c *config) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { rawURL, err := url.Parse(link) if err != nil { return err } _ = c.Deactivate(ctx, u, r, link) return c.newClient(ctx, u).CreateHook(r.Owner, r.Name, &internal.Hook{ Active: true, Desc: rawURL.Host, Events: []string{"repo:push", "pullrequest:created", "pullrequest:updated", "pullrequest:fulfilled", "pullrequest:rejected"}, URL: link, }) } // Deactivate deactivates the repository be removing repository push hooks from // the Bitbucket repository. func (c *config) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { client := c.newClient(ctx, u) // check repo exists if _, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name); err != nil { return fmt.Errorf("repo online check failed: %w", err) } hooks, err := shared_utils.Paginate(func(page int) ([]*internal.Hook, error) { hooks, err := client.ListHooks(r.Owner, r.Name, &internal.ListOpts{ Page: page, }) if err != nil { return nil, err } return hooks.Values, nil }, -1) if err != nil { return err } hook := matchingHooks(hooks, link) if hook != nil { return client.DeleteHook(r.Owner, r.Name, hook.UUID) } return nil } // Netrc returns a netrc file capable of authenticating Bitbucket requests and // cloning Bitbucket repositories. func (c *config) Netrc(u *model.User, _ *model.Repo) (*model.Netrc, error) { return &model.Netrc{ Machine: "bitbucket.org", Login: "x-token-auth", Password: u.AccessToken, Type: model.ForgeTypeBitbucket, }, nil } // Branches returns the names of all branches for the named repository. func (c *config) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { setListOptions(p) opts := internal.ListOpts{Page: p.Page, PageLen: p.PerPage} bitbucketBranches, err := c.newClient(ctx, u).ListBranches(r.Owner, r.Name, &opts) if err != nil { return nil, err } branches := make([]string, 0) for _, branch := range bitbucketBranches { branches = append(branches, branch.Name) } return branches, nil } // BranchHead returns the sha of the head (latest commit) of the specified branch. func (c *config) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { commit, err := c.newClient(ctx, u).GetBranchHead(r.Owner, r.Name, branch) if err != nil { return nil, err } return &model.Commit{ SHA: commit.Hash, ForgeURL: commit.Links.HTML.Href, }, nil } // PullRequests returns the pull requests of the named repository. func (c *config) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { setListOptions(p) opts := internal.ListOpts{Page: p.Page, PageLen: p.PerPage} pullRequests, err := c.newClient(ctx, u).ListPullRequests(r.Owner, r.Name, &opts) if err != nil { return nil, err } var result []*model.PullRequest for _, pullRequest := range pullRequests { result = append(result, &model.PullRequest{ Index: model.ForgeRemoteID(strconv.Itoa(int(pullRequest.ID))), Title: pullRequest.Title, }) } return result, nil } // Hook parses the incoming Bitbucket hook and returns the Repository and // Pipeline details. If the hook is unsupported nil values are returned. func (c *config) Hook(ctx context.Context, req *http.Request) (*model.Repo, *model.Pipeline, error) { pr, repo, pl, err := parseHook(req) if err != nil { return nil, nil, err } u, err := common.RepoUserForgeID(ctx, c.forgeID, repo.ForgeRemoteID) if err != nil { return nil, nil, err } // Refresh the OAuth token before making API calls. // The token may be expired, and without this refresh the API calls below // would fail with "OAuth2 access token expired" error. _store, ok := store.TryFromContext(ctx) if ok { forge.Refresh(ctx, c, _store, u) } switch pl.Event { case model.EventPush: // List only the latest push changes pl.ChangedFiles, err = c.newClient(ctx, u).ListChangedFiles(repo.Owner, repo.Name, pl.Commit) if err != nil { return nil, nil, err } case model.EventPull: client := c.newClient(ctx, u) if pr == nil { return nil, nil, fmt.Errorf("can't run hook against empty PR information") } // List all changes between source & destination branch pl.ChangedFiles, err = client.ListChangedFiles(repo.Owner, repo.Name, fmt.Sprintf("%s..%s", pr.PullRequest.Source.Branch.Name, pr.PullRequest.Dest.Branch.Name)) if err != nil { return nil, nil, err } } repo, err = c.Repo(ctx, u, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return nil, nil, err } return repo, pl, nil } // OrgMembership returns if user is member of organization and if user // is admin/owner in this organization. func (c *config) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) { perm, err := c.newClient(ctx, u).GetUserWorkspaceMembership(owner, u.Login) if err != nil { return nil, err } return &model.OrgPerm{Member: perm != "", Admin: perm == "owner"}, nil } func (c *config) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) { workspace, err := c.newClient(ctx, u).GetWorkspace(owner) if err != nil { return nil, err } return &model.Org{ Name: workspace.Slug, IsUser: false, // bitbucket uses workspaces (similar to orgs) for teams and single users so we cannot distinguish between them }, nil } // helper function to return the bitbucket oauth2 client. func (c *config) newClient(ctx context.Context, u *model.User) *internal.Client { if u == nil { return c.newClientToken(ctx, "", "") } return c.newClientToken(ctx, u.AccessToken, u.RefreshToken) } // helper function to return the bitbucket oauth2 client. func (c *config) newClientToken(ctx context.Context, accessToken, refreshToken string) *internal.Client { client := internal.NewClientToken( ctx, c.api, accessToken, refreshToken, &oauth2.Token{ AccessToken: accessToken, RefreshToken: refreshToken, }, ) client.Client = httputil.WrapClient(client.Client, "forge-bitbucket") return client } // helper function to return the bitbucket oauth2 config. func (c *config) newOAuth2Config() *oauth2.Config { return &oauth2.Config{ ClientID: c.oAuthClientID, ClientSecret: c.oAuthSecret, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("%s/site/oauth2/authorize", c.url), TokenURL: fmt.Sprintf("%s/site/oauth2/access_token", c.url), }, RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), } } // helper function to return matching hooks. func matchingHooks(hooks []*internal.Hook, rawURL string) *internal.Hook { link, err := url.Parse(rawURL) if err != nil { return nil } for _, hook := range hooks { hookURL, err := url.Parse(hook.URL) if err == nil && hookURL.Host == link.Host { return hook } } return nil } func setListOptions(p *model.ListOptions) { if p.PerPage > pageSize || p.PerPage == 0 { p.PerPage = pageSize } } ================================================ FILE: server/forge/bitbucket/bitbucket_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucket import ( "bytes" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestNew(t *testing.T) { forge, _ := New(1, &Opts{OAuthClientID: "4vyW6b49Z", OAuthClientSecret: "a5012f6c6"}) f, _ := forge.(*config) assert.Equal(t, DefaultURL, f.url) assert.Equal(t, DefaultAPI, f.api) assert.Equal(t, "4vyW6b49Z", f.oAuthClientID) assert.Equal(t, "a5012f6c6", f.oAuthSecret) } func TestBitbucket(t *testing.T) { gin.SetMode(gin.TestMode) s := httptest.NewServer(fixtures.Handler()) defer s.Close() c := &config{url: s.URL, api: s.URL} ctx := t.Context() forge, _ := New(1, &Opts{}) netrc, _ := forge.Netrc(fakeUser, fakeRepo) assert.Equal(t, "bitbucket.org", netrc.Machine) assert.Equal(t, "x-token-auth", netrc.Login) assert.Equal(t, fakeUser.AccessToken, netrc.Password) assert.Equal(t, model.ForgeTypeBitbucket, netrc.Type) user, _, err := c.Login(ctx, &types.OAuthRequest{}) assert.NoError(t, err) assert.Nil(t, user) u, _, err := c.Login(ctx, &types.OAuthRequest{ Code: "code", }) assert.NoError(t, err) assert.Equal(t, fakeUser.Login, u.Login) assert.Equal(t, "2YotnFZFEjr1zCsicMWpAA", u.AccessToken) assert.Equal(t, "tGzv3JOkF0XG5Qx2TlKWIA", u.RefreshToken) _, _, err = c.Login(ctx, &types.OAuthRequest{ Code: "code_bad_request", }) assert.Error(t, err) _, _, err = c.Login(ctx, &types.OAuthRequest{ Code: "code_user_not_found", }) assert.Error(t, err) ok, err := c.Refresh(ctx, fakeUserRefresh) assert.NoError(t, err) assert.True(t, ok) assert.Equal(t, "2YotnFZFEjr1zCsicMWpAA", fakeUserRefresh.AccessToken) assert.Equal(t, "tGzv3JOkF0XG5Qx2TlKWIA", fakeUserRefresh.RefreshToken) ok, err = c.Refresh(ctx, fakeUserRefreshEmpty) assert.Error(t, err) assert.False(t, ok) ok, err = c.Refresh(ctx, fakeUserRefreshFail) assert.Error(t, err) assert.False(t, ok) repo, err := c.Repo(ctx, fakeUser, "", fakeRepo.Owner, fakeRepo.Name) assert.NoError(t, err) assert.Equal(t, fakeRepo.FullName, repo.FullName) _, err = c.Repo(ctx, fakeUser, "", fakeRepoNotFound.Owner, fakeRepoNotFound.Name) assert.Error(t, err) repos, err := c.Repos(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10}) assert.NoError(t, err) assert.Len(t, repos, 1) assert.Equal(t, fakeRepo.FullName, repos[0].FullName) _, err = c.Repos(ctx, fakeUserNoTeams, &model.ListOptions{Page: 1, PerPage: 10}) assert.Error(t, err) _, err = c.Repos(ctx, fakeUserNoRepos, &model.ListOptions{Page: 1, PerPage: 10}) assert.Error(t, err) teams, err := c.Teams(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10}) assert.NoError(t, err) assert.Equal(t, "test_name", teams[0].Login) assert.Equal(t, "https://bitbucket.org/workspaces/ueberdev42/avatar/?ts=1658761964", teams[0].Avatar) _, err = c.Teams(ctx, fakeUserNoTeams, &model.ListOptions{Page: 1, PerPage: 10}) assert.Error(t, err) raw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, "file") assert.NoError(t, err) assert.True(t, len(raw) != 0) _, err = c.File(ctx, fakeUser, fakeRepo, fakePipeline, "file_not_found") assert.Error(t, err) assert.ErrorIs(t, err, &types.ErrConfigNotFound{}) branchHead, err := c.BranchHead(ctx, fakeUser, fakeRepo, "branch_name") assert.NoError(t, err) assert.Equal(t, "branch_head_name", branchHead.SHA) assert.Equal(t, "https://bitbucket.org/commitlink", branchHead.ForgeURL) _, err = c.BranchHead(ctx, fakeUser, fakeRepo, "branch_not_found") assert.Error(t, err) listOpts := model.ListOptions{ All: false, Page: 1, PerPage: 10, } repoPRs, err := c.PullRequests(ctx, fakeUser, fakeRepo, &listOpts) assert.NoError(t, err) assert.Equal(t, "PRs title", repoPRs[0].Title) assert.Equal(t, model.ForgeRemoteID("123"), repoPRs[0].Index) _, err = c.PullRequests(ctx, fakeUser, fakeRepoNotFound, &listOpts) assert.Error(t, err) files, err := c.Dir(ctx, fakeUser, fakeRepo, fakePipeline, "dir") assert.NoError(t, err) assert.Len(t, files, 3) assert.Equal(t, "README.md", files[0].Name) assert.Equal(t, "dummy payload", string(files[0].Data)) _, err = c.Dir(ctx, fakeUser, fakeRepo, fakePipeline, "dir_not_found") assert.Error(t, err) assert.ErrorIs(t, err, &types.ErrConfigNotFound{}) err = c.Activate(ctx, fakeUser, fakeRepo, "%gh&%ij") assert.Error(t, err) err = c.Activate(ctx, fakeUser, fakeRepo, "http://127.0.0.1") assert.NoError(t, err) err = c.Deactivate(ctx, fakeUser, fakeRepoNoHooks, "http://127.0.0.1") assert.Error(t, err) err = c.Deactivate(ctx, fakeUser, fakeRepo, "http://127.0.0.1") assert.NoError(t, err) err = c.Deactivate(ctx, fakeUser, fakeRepoEmptyHook, "http://127.0.0.1") assert.NoError(t, err) hooks := []*internal.Hook{ {URL: "http://127.0.0.1/hook"}, } hook := matchingHooks(hooks, "http://127.0.0.1/") assert.Equal(t, hooks[0], hook) hooks = []*internal.Hook{ {URL: "http://localhost/hook"}, } hook = matchingHooks(hooks, "http://127.0.0.1/") assert.Nil(t, hook) hooks = nil hook = matchingHooks(hooks, "%gh&%ij") assert.Nil(t, hook) err = c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow) assert.NoError(t, err) buf := bytes.NewBufferString(fixtures.HookPush) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPush) mockStore := store_mocks.NewMockStore(t) ctx = store.InjectToContext(ctx, mockStore) mockStore.On("GetUser", mock.Anything).Return(fakeUser, nil) mockStore.On("GetRepoForgeID", mock.Anything, mock.Anything).Return(fakeRepoFromHook, nil) r, b, err := c.Hook(ctx, req) assert.NoError(t, err) assert.Equal(t, "martinherren1984/publictestrepo", r.FullName) assert.Equal(t, "master", r.Branch) assert.Equal(t, "c14c1bb05dfb1fdcdf06b31485fff61b0ea44277", b.Commit) assert.Equal(t, []string{"main.go"}, b.ChangedFiles) } var ( fakeUser = &model.User{ Login: "superman", AccessToken: "cfcd2084", } fakeUserRefresh = &model.User{ Login: "superman", RefreshToken: "cfcd2084", } fakeUserRefreshFail = &model.User{ Login: "superman", RefreshToken: "refresh_token_not_found", } fakeUserRefreshEmpty = &model.User{ Login: "superman", RefreshToken: "refresh_token_is_empty", } fakeUserNoTeams = &model.User{ Login: "superman", AccessToken: "teams_not_found", } fakeUserNoRepos = &model.User{ Login: "superman", AccessToken: "repos_not_found", } fakeRepo = &model.Repo{ Owner: "test_name", Name: "repo_name", FullName: "test_name/repo_name", } fakeRepoNotFound = &model.Repo{ Owner: "test_name", Name: "repo_not_found", FullName: "test_name/repo_not_found", } fakeRepoNoHooks = &model.Repo{ Owner: "test_name", Name: "hooks_not_found", FullName: "test_name/hooks_not_found", } fakeRepoEmptyHook = &model.Repo{ Owner: "test_name", Name: "hook_empty", FullName: "test_name/hook_empty", } fakeRepoFromHook = &model.Repo{ Owner: "martinherren1984", Name: "publictestrepo", FullName: "martinherren1984/publictestrepo", UserID: 1, } fakePipeline = &model.Pipeline{ Commit: "9ecad50", } fakeWorkflow = &model.Workflow{ Name: "test", State: model.StatusSuccess, } ) ================================================ FILE: server/forge/bitbucket/convert.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucket import ( "fmt" "net/url" "regexp" "strings" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( statusPending = "INPROGRESS" // cspell:disable-line statusSuccess = "SUCCESSFUL" statusFailure = "FAILED" ) // convertStatus is a helper function used to convert a Woodpecker status to a // Bitbucket commit status. func convertStatus(status model.StatusValue) string { switch status { case model.StatusPending, model.StatusRunning, model.StatusBlocked: return statusPending case model.StatusSuccess: return statusSuccess default: return statusFailure } } // convertRepo is a helper function used to convert a Bitbucket repository // structure to the common Woodpecker repository structure. func convertRepo(from *internal.Repo, perm *internal.RepoPerm) *model.Repo { repo := model.Repo{ ForgeRemoteID: model.ForgeRemoteID(from.UUID), Clone: cloneLink(from), CloneSSH: sshCloneLink(from), Owner: strings.Split(from.FullName, "/")[0], Name: strings.Split(from.FullName, "/")[1], FullName: from.FullName, ForgeURL: from.Links.HTML.Href, IsSCMPrivate: from.IsPrivate, Avatar: from.Owner.Links.Avatar.Href, Branch: from.MainBranch.Name, Perm: convertPerm(perm), PREnabled: true, } return &repo } func convertPerm(from *internal.RepoPerm) *model.Perm { perms := new(model.Perm) switch from.Permission { case "admin": perms.Admin = true fallthrough case "write": perms.Push = true fallthrough default: perms.Pull = true } return perms } // cloneLink is a helper function that tries to extract the clone url from the // repository object. func cloneLink(repo *internal.Repo) string { var clone string // above we manually constructed the repository clone url. below we will // iterate through the list of clone links and attempt to instead use the // clone url provided by bitbucket. for _, link := range repo.Links.Clone { if link.Name == "https" { clone = link.Href } } // if no repository name is provided, we use the Html link. this excludes the // .git suffix, but will still clone the repo. if len(clone) == 0 { clone = repo.Links.HTML.Href } // if bitbucket tries to automatically populate the user in the url we must // strip it out. cloneURL, err := url.Parse(clone) if err == nil { cloneURL.User = nil clone = cloneURL.String() } return clone } // cloneLink is a helper function that tries to extract the clone url from the // repository object. func sshCloneLink(repo *internal.Repo) string { for _, link := range repo.Links.Clone { if link.Name == "ssh" { return link.Href } } return "" } // convertUser is a helper function used to convert a Bitbucket user account // structure to the Woodpecker User structure. func convertUser(from *internal.Account, token *oauth2.Token, email string) *model.User { return &model.User{ Login: from.Login, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry.UTC().Unix(), Avatar: from.Links.Avatar.Href, ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.UUID)), Email: email, } } // convertWorkspaceList is a helper function used to convert a Bitbucket team list // structure to the Woodpecker Team structure. func convertWorkspaceList(from []*internal.Workspace) []*model.Team { var teams []*model.Team for _, workspace := range from { teams = append(teams, convertWorkspace(workspace)) } return teams } // convertWorkspace is a helper function used to convert a Bitbucket team account // structure to the Woodpecker Team structure. func convertWorkspace(from *internal.Workspace) *model.Team { return &model.Team{ Login: from.Slug, Avatar: from.Links.Avatar.Href, } } // convertPullHook is a helper function used to convert a Bitbucket pull request // hook to the Woodpecker pipeline struct holding commit information. func convertPullHook(from *internal.PullRequestHook) *model.Pipeline { event := model.EventPull if from.PullRequest.State == stateClosed || from.PullRequest.State == stateDeclined { event = model.EventPullClosed } pipeline := &model.Pipeline{ Event: event, Commit: from.PullRequest.Source.Commit.Hash, Ref: fmt.Sprintf("refs/pull-requests/%d/from", from.PullRequest.ID), Refspec: fmt.Sprintf("%s:%s", from.PullRequest.Source.Branch.Name, from.PullRequest.Dest.Branch.Name, ), ForgeURL: from.PullRequest.Links.HTML.Href, Branch: from.PullRequest.Source.Branch.Name, Message: from.PullRequest.Title, Avatar: from.Actor.Links.Avatar.Href, Author: from.Actor.Login, Sender: from.Actor.Login, Timestamp: from.PullRequest.Updated.UTC().Unix(), FromFork: from.PullRequest.Source.Repo.UUID != from.PullRequest.Dest.Repo.UUID, } if from.PullRequest.State == stateClosed { pipeline.Commit = from.PullRequest.MergeCommit.Hash pipeline.Ref = fmt.Sprintf("refs/heads/%s", from.PullRequest.Dest.Branch.Name) pipeline.Branch = from.PullRequest.Dest.Branch.Name } return pipeline } // convertPushHook is a helper function used to convert a Bitbucket push // hook to the Woodpecker pipeline struct holding commit information. func convertPushHook(hook *internal.PushHook, change *internal.Change) *model.Pipeline { pipeline := &model.Pipeline{ Commit: change.New.Target.Hash, ForgeURL: change.New.Target.Links.HTML.Href, Branch: change.New.Name, Message: change.New.Target.Message, Avatar: hook.Actor.Links.Avatar.Href, Author: hook.Actor.Login, Sender: hook.Actor.Login, Timestamp: change.New.Target.Date.UTC().Unix(), } switch change.New.Type { case "tag", "annotated_tag", "bookmark": pipeline.Event = model.EventTag pipeline.Ref = fmt.Sprintf("refs/tags/%s", change.New.Name) default: pipeline.Event = model.EventPush pipeline.Ref = fmt.Sprintf("refs/heads/%s", change.New.Name) } if len(change.New.Target.Author.Raw) != 0 { pipeline.Email = extractEmail(change.New.Target.Author.Raw) } return pipeline } // regex for git author fields (r.g. "name "). var reGitMail = regexp.MustCompile("<(.*)>") // extracts the email from a git commit author string. func extractEmail(gitAuthor string) (author string) { matches := reGitMail.FindAllStringSubmatch(gitAuthor, -1) if len(matches) == 1 { author = matches[0][1] } return author } ================================================ FILE: server/forge/bitbucket/convert_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucket import ( "testing" "time" "github.com/stretchr/testify/assert" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_convertStatus(t *testing.T) { assert.Equal(t, statusSuccess, convertStatus(model.StatusSuccess)) assert.Equal(t, statusPending, convertStatus(model.StatusPending)) assert.Equal(t, statusPending, convertStatus(model.StatusRunning)) assert.Equal(t, statusFailure, convertStatus(model.StatusFailure)) assert.Equal(t, statusFailure, convertStatus(model.StatusKilled)) assert.Equal(t, statusFailure, convertStatus(model.StatusError)) } func Test_convertRepo(t *testing.T) { from := &internal.Repo{ FullName: "octocat/hello-world", IsPrivate: true, Scm: "git", } from.Owner.Links.Avatar.Href = "http://..." from.Links.HTML.Href = "https://bitbucket.org/foo/bar" from.MainBranch.Name = "default" fromPerm := &internal.RepoPerm{ Permission: "write", } to := convertRepo(from, fromPerm) assert.Equal(t, from.Owner.Links.Avatar.Href, to.Avatar) assert.Equal(t, from.FullName, to.FullName) assert.Equal(t, "octocat", to.Owner) assert.Equal(t, "hello-world", to.Name) assert.Equal(t, "default", to.Branch) assert.Equal(t, from.IsPrivate, to.IsSCMPrivate) assert.Equal(t, from.Links.HTML.Href, to.Clone) assert.Equal(t, from.Links.HTML.Href, to.ForgeURL) assert.True(t, to.Perm.Push) assert.False(t, to.Perm.Admin) } func Test_convertWorkspace(t *testing.T) { from := &internal.Workspace{Slug: "octocat"} from.Links.Avatar.Href = "http://..." to := convertWorkspace(from) assert.Equal(t, from.Links.Avatar.Href, to.Avatar) assert.Equal(t, from.Slug, to.Login) } func Test_convertWorkspaceList(t *testing.T) { from := &internal.Workspace{Slug: "octocat"} from.Links.Avatar.Href = "http://..." to := convertWorkspaceList([]*internal.Workspace{from}) assert.Equal(t, from.Links.Avatar.Href, to[0].Avatar) assert.Equal(t, from.Slug, to[0].Login) } func Test_convertUser(t *testing.T) { token := &oauth2.Token{ AccessToken: "foo", RefreshToken: "bar", Expiry: time.Now(), } user := &internal.Account{Login: "octocat"} user.Links.Avatar.Href = "http://..." result := convertUser(user, token, "test@example.com") assert.Equal(t, user.Links.Avatar.Href, result.Avatar) assert.Equal(t, user.Login, result.Login) assert.Equal(t, "test@example.com", result.Email) assert.Equal(t, token.AccessToken, result.AccessToken) assert.Equal(t, token.RefreshToken, result.RefreshToken) assert.Equal(t, token.Expiry.UTC().Unix(), result.Expiry) } func Test_cloneLink(t *testing.T) { repo := &internal.Repo{} repo.Links.Clone = append(repo.Links.Clone, internal.Link{ Name: "https", Href: "https://bitbucket.org/foo/bar.git", }) link := cloneLink(repo) assert.Equal(t, repo.Links.Clone[0].Href, link) repo = &internal.Repo{} repo.Links.HTML.Href = "https://foo:bar@bitbucket.org/foo/bar.git" link = cloneLink(repo) assert.Equal(t, "https://bitbucket.org/foo/bar.git", link) } func Test_convertPullHook(t *testing.T) { hook := &internal.PullRequestHook{} hook.Actor.Login = "octocat" hook.Actor.Links.Avatar.Href = "https://..." hook.PullRequest.Dest.Commit.Hash = "73f9c44d" hook.PullRequest.Dest.Branch.Name = "main" hook.PullRequest.Dest.Repo.Links.HTML.Href = "https://bitbucket.org/foo/bar" hook.PullRequest.Source.Branch.Name = "change" hook.PullRequest.Source.Repo.FullName = "baz/bar" hook.PullRequest.Source.Commit.Hash = "c8411d7" hook.PullRequest.Links.HTML.Href = "https://bitbucket.org/foo/bar/pulls/5" hook.PullRequest.Title = "updated README" hook.PullRequest.Updated = time.Now() hook.PullRequest.ID = 1 pipeline := convertPullHook(hook) assert.Equal(t, model.EventPull, pipeline.Event) assert.Equal(t, hook.Actor.Login, pipeline.Author) assert.Equal(t, hook.Actor.Links.Avatar.Href, pipeline.Avatar) assert.Equal(t, hook.PullRequest.Source.Commit.Hash, pipeline.Commit) assert.Equal(t, hook.PullRequest.Source.Branch.Name, pipeline.Branch) assert.Equal(t, hook.PullRequest.Links.HTML.Href, pipeline.ForgeURL) assert.Equal(t, "refs/pull-requests/1/from", pipeline.Ref) assert.Equal(t, "change:main", pipeline.Refspec) assert.Equal(t, hook.PullRequest.Title, pipeline.Message) assert.Equal(t, hook.PullRequest.Updated.Unix(), pipeline.Timestamp) } func Test_convertPushHook(t *testing.T) { change := internal.Change{} change.New.Target.Hash = "73f9c44d" change.New.Name = "main" change.New.Target.Links.HTML.Href = "https://bitbucket.org/foo/bar/commits/73f9c44d" change.New.Target.Message = "updated README" change.New.Target.Date = time.Now() change.New.Target.Author.Raw = "Test " hook := internal.PushHook{} hook.Actor.Login = "octocat" hook.Actor.Links.Avatar.Href = "https://..." pipeline := convertPushHook(&hook, &change) assert.Equal(t, model.EventPush, pipeline.Event) assert.Equal(t, "test@domain.tld", pipeline.Email) assert.Equal(t, hook.Actor.Login, pipeline.Author) assert.Equal(t, hook.Actor.Links.Avatar.Href, pipeline.Avatar) assert.Equal(t, change.New.Target.Hash, pipeline.Commit) assert.Equal(t, change.New.Name, pipeline.Branch) assert.Equal(t, change.New.Target.Links.HTML.Href, pipeline.ForgeURL) assert.Equal(t, "refs/heads/main", pipeline.Ref) assert.Equal(t, change.New.Target.Message, pipeline.Message) assert.Equal(t, change.New.Target.Date.Unix(), pipeline.Timestamp) } func Test_convertPushHookTag(t *testing.T) { change := internal.Change{} change.New.Name = "v1.0.0" change.New.Type = "tag" hook := internal.PushHook{} pipeline := convertPushHook(&hook, &change) assert.Equal(t, model.EventTag, pipeline.Event) assert.Equal(t, "refs/tags/v1.0.0", pipeline.Ref) } ================================================ FILE: server/forge/bitbucket/fixtures/HookPull.json ================================================ { "actor": { "username": "emmap1", "links": { "avatar": { "href": "https://bitbucket-api-assetroot.s3.amazonaws.com/c/photos/2015/Feb/26/3613917261-0-emmap1-avatar_avatar.png" } } }, "pullrequest": { "id": 1, "title": "Title of pull request", "description": "Description of pull request", "state": "OPEN", "author": { "username": "emmap1", "links": { "avatar": { "href": "https://bitbucket-api-assetroot.s3.amazonaws.com/c/photos/2015/Feb/26/3613917261-0-emmap1-avatar_avatar.png" } } }, "source": { "branch": { "name": "branch2" }, "commit": { "hash": "d3022fc0ca3d" }, "repository": { "links": { "html": { "href": "https://api.bitbucket.org/team_name/repo_name" }, "avatar": { "href": "https://api-staging-assetroot.s3.amazonaws.com/c/photos/2014/Aug/01/bitbucket-logo-2629490769-3_avatar.png" } }, "full_name": "user_name/repo_name", "scm": "git", "is_private": true } }, "destination": { "branch": { "name": "main" }, "commit": { "hash": "ce5965ddd289" }, "repository": { "links": { "html": { "href": "https://api.bitbucket.org/team_name/repo_name" }, "avatar": { "href": "https://api-staging-assetroot.s3.amazonaws.com/c/photos/2014/Aug/01/bitbucket-logo-2629490769-3_avatar.png" } }, "full_name": "user_name/repo_name", "scm": "git", "is_private": true } }, "links": { "self": { "href": "https://api.bitbucket.org/api/2.0/pullrequests/pullrequest_id" }, "html": { "href": "https://api.bitbucket.org/pullrequest_id" } } }, "repository": { "links": { "html": { "href": "https://api.bitbucket.org/team_name/repo_name" }, "avatar": { "href": "https://api-staging-assetroot.s3.amazonaws.com/c/photos/2014/Aug/01/bitbucket-logo-2629490769-3_avatar.png" } }, "full_name": "user_name/repo_name", "scm": "git", "is_private": true } } ================================================ FILE: server/forge/bitbucket/fixtures/HookPullRequestDeclined.json ================================================ { "repository": { "type": "repository", "full_name": "anbraten/test-2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default" } }, "name": "test-2", "scm": "git", "website": null, "owner": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "workspace": { "type": "workspace", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "name": "Anbraten", "slug": "anbraten", "links": { "avatar": { "href": "https://bitbucket.org/workspaces/anbraten/avatar/?ts=1651865281" }, "html": { "href": "https://bitbucket.org/anbraten/" }, "self": { "href": "https://api.bitbucket.org/2.0/workspaces/anbraten" } } }, "is_private": true, "project": { "type": "project", "key": "TEST", "uuid": "{3fa6429f-95e1-4c5a-875c-1753abcd8ace}", "name": "test", "links": { "self": { "href": "https://api.bitbucket.org/2.0/workspaces/anbraten/projects/TEST" }, "html": { "href": "https://bitbucket.org/anbraten/workspace/projects/TEST" }, "avatar": { "href": "https://bitbucket.org/account/user/anbraten/projects/TEST/avatar/32?ts=1690725373" } } }, "uuid": "{26554729-595f-47d1-aedd-302625cb4a97}", "parent": null }, "actor": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "pullrequest": { "comment_count": 0, "task_count": 0, "type": "pullrequest", "id": 2, "title": "CHANGELOG.md created online with Bitbucket", "description": "CHANGELOG.md created online with Bitbucket", "rendered": { "title": { "type": "rendered", "raw": "CHANGELOG.md created online with Bitbucket", "markup": "markdown", "html": "

CHANGELOG.md created online with Bitbucket

" }, "description": { "type": "rendered", "raw": "CHANGELOG.md created online with Bitbucket", "markup": "markdown", "html": "

CHANGELOG.md created online with Bitbucket

" } }, "state": "DECLINED", "merge_commit": null, "close_source_branch": false, "closed_by": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "author": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "reason": "", "created_on": "2023-12-05T18:36:27.667680+00:00", "updated_on": "2023-12-05T18:36:57.260672+00:00", "destination": { "branch": { "name": "main" }, "commit": { "type": "commit", "hash": "006704dbeab2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/006704dbeab2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/commits/006704dbeab2" } } }, "repository": { "type": "repository", "full_name": "anbraten/test-2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default" } }, "name": "test-2", "uuid": "{26554729-595f-47d1-aedd-302625cb4a97}" } }, "source": { "branch": { "name": "patch-2" }, "commit": { "type": "commit", "hash": "f90e18fc9d45", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/f90e18fc9d45" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/commits/f90e18fc9d45" } } }, "repository": { "type": "repository", "full_name": "anbraten/test-2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default" } }, "name": "test-2", "uuid": "{26554729-595f-47d1-aedd-302625cb4a97}" } }, "reviewers": [], "participants": [], "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/pull-requests/2" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/commits" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/approve" }, "request-changes": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/request-changes" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diff/anbraten/test-2:f90e18fc9d45%0D006704dbeab2?from_pullrequest_id=2&topic=true" }, "diffstat": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diffstat/anbraten/test-2:f90e18fc9d45%0D006704dbeab2?from_pullrequest_id=2&topic=true" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/comments" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/activity" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/merge" }, "decline": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/decline" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/2/statuses" } }, "summary": { "type": "rendered", "raw": "CHANGELOG.md created online with Bitbucket", "markup": "markdown", "html": "

CHANGELOG.md created online with Bitbucket

" } } } ================================================ FILE: server/forge/bitbucket/fixtures/HookPullRequestMerged.json ================================================ { "repository": { "type": "repository", "full_name": "anbraten/test-2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default" } }, "name": "test-2", "scm": "git", "website": null, "owner": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "workspace": { "type": "workspace", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "name": "Anbraten", "slug": "anbraten", "links": { "avatar": { "href": "https://bitbucket.org/workspaces/anbraten/avatar/?ts=1651865281" }, "html": { "href": "https://bitbucket.org/anbraten/" }, "self": { "href": "https://api.bitbucket.org/2.0/workspaces/anbraten" } } }, "is_private": true, "project": { "type": "project", "key": "TEST", "uuid": "{3fa6429f-95e1-4c5a-875c-1753abcd8ace}", "name": "test", "links": { "self": { "href": "https://api.bitbucket.org/2.0/workspaces/anbraten/projects/TEST" }, "html": { "href": "https://bitbucket.org/anbraten/workspace/projects/TEST" }, "avatar": { "href": "https://bitbucket.org/account/user/anbraten/projects/TEST/avatar/32?ts=1690725373" } } }, "uuid": "{26554729-595f-47d1-aedd-302625cb4a97}", "parent": null }, "actor": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "pullrequest": { "comment_count": 0, "task_count": 0, "type": "pullrequest", "id": 1, "title": "README.md created online with Bitbucket", "description": "README.md created online with Bitbucket", "rendered": { "title": { "type": "rendered", "raw": "README.md created online with Bitbucket", "markup": "markdown", "html": "

README.md created online with Bitbucket

" }, "description": { "type": "rendered", "raw": "README.md created online with Bitbucket", "markup": "markdown", "html": "

README.md created online with Bitbucket

" } }, "state": "MERGED", "merge_commit": { "type": "commit", "hash": "006704dbeab2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/006704dbeab2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/commits/006704dbeab2" } } }, "close_source_branch": true, "closed_by": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "author": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "reason": "", "created_on": "2023-12-05T18:28:16.861881+00:00", "updated_on": "2023-12-05T18:29:44.785393+00:00", "destination": { "branch": { "name": "main" }, "commit": { "type": "commit", "hash": "6c5f0bc9b2aa", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/6c5f0bc9b2aa" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/commits/6c5f0bc9b2aa" } } }, "repository": { "type": "repository", "full_name": "anbraten/test-2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default" } }, "name": "test-2", "uuid": "{26554729-595f-47d1-aedd-302625cb4a97}" } }, "source": { "branch": { "name": "patch-2" }, "commit": { "type": "commit", "hash": "668218c13e04", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/commit/668218c13e04" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/commits/668218c13e04" } } }, "repository": { "type": "repository", "full_name": "anbraten/test-2", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2" }, "html": { "href": "https://bitbucket.org/anbraten/test-2" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B26554729-595f-47d1-aedd-302625cb4a97%7D?ts=default" } }, "name": "test-2", "uuid": "{26554729-595f-47d1-aedd-302625cb4a97}" } }, "reviewers": [], "participants": [ { "type": "participant", "user": { "display_name": "Anbraten", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D" }, "avatar": { "href": "https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e/784add1f-95cc-42a5-a562-38a0e12de4fa/128" }, "html": { "href": "https://bitbucket.org/%7Bb1b7beef-77ca-452d-b059-fa092504ebd7%7D/" } }, "type": "user", "uuid": "{b1b7beef-77ca-452d-b059-fa092504ebd7}", "account_id": "70121:3046ad5f-946f-48fa-bcb4-a399eef48f0e", "nickname": "Anbraten" }, "role": "PARTICIPANT", "approved": true, "state": "approved", "participated_on": "2023-12-05T18:29:25.611876+00:00" } ], "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1" }, "html": { "href": "https://bitbucket.org/anbraten/test-2/pull-requests/1" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/commits" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/approve" }, "request-changes": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/request-changes" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diff/anbraten/test-2:668218c13e04%0D6c5f0bc9b2aa?from_pullrequest_id=1&topic=true" }, "diffstat": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/diffstat/anbraten/test-2:668218c13e04%0D6c5f0bc9b2aa?from_pullrequest_id=1&topic=true" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/comments" }, "activity": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/activity" }, "merge": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/merge" }, "decline": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/decline" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/anbraten/test-2/pullrequests/1/statuses" } }, "summary": { "type": "rendered", "raw": "README.md created online with Bitbucket", "markup": "markdown", "html": "

README.md created online with Bitbucket

" } } } ================================================ FILE: server/forge/bitbucket/fixtures/HookPush.json ================================================ { "actor": { "display_name": "Martin Herren", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D" }, "avatar": { "href": "https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png" }, "html": { "href": "https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/" } }, "type": "user", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "account_id": "5cf8e3a9678ca90f8e7cc8a8", "nickname": "Martin Herren" }, "repository": { "type": "repository", "full_name": "martinherren1984/publictestrepo", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B898477b2-a080-4089-b385-597a783db392%7D?ts=default" } }, "name": "PublicTestRepo", "scm": "git", "website": null, "owner": { "display_name": "Martin Herren", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D" }, "avatar": { "href": "https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png" }, "html": { "href": "https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/" } }, "type": "user", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "account_id": "5cf8e3a9678ca90f8e7cc8a8", "nickname": "Martin Herren" }, "workspace": { "type": "workspace", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "name": "Martin Herren", "slug": "martinherren1984", "links": { "avatar": { "href": "https://bitbucket.org/workspaces/martinherren1984/avatar/?ts=1658761964" }, "html": { "href": "https://bitbucket.org/martinherren1984/" }, "self": { "href": "https://api.bitbucket.org/2.0/workspaces/martinherren1984" } } }, "is_private": false, "project": { "type": "project", "key": "PUB", "uuid": "{2cede481-f59e-49ec-88d0-a85629b7925d}", "name": "PublicTestProject", "links": { "self": { "href": "https://api.bitbucket.org/2.0/workspaces/martinherren1984/projects/PUB" }, "html": { "href": "https://bitbucket.org/martinherren1984/workspace/projects/PUB" }, "avatar": { "href": "https://bitbucket.org/account/user/martinherren1984/projects/PUB/avatar/32?ts=1658768453" } } }, "uuid": "{898477b2-a080-4089-b385-597a783db392}" }, "push": { "changes": [ { "old": { "name": "main", "target": { "type": "commit", "hash": "a51241ae1f00cbe728930db48e890b18fd527f99", "date": "2022-08-17T15:24:29+00:00", "author": { "type": "author", "raw": "Martin Herren ", "user": { "display_name": "Martin Herren", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7B69cc59f2-706b-4a9c-b99c-eac2ace320da%7D" }, "avatar": { "href": "https://secure.gravatar.com/avatar/7b2e50690b4ab7bb9e1db18ea3b8ae95?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-5.png" }, "html": { "href": "https://bitbucket.org/%7B69cc59f2-706b-4a9c-b99c-eac2ace320da%7D/" } }, "type": "user", "uuid": "{69cc59f2-706b-4a9c-b99c-eac2ace320da}", "account_id": "5d286e857133f10c17e026cb", "nickname": "Martin Herren" } }, "message": "Add test .woodpecker.yml\n", "summary": { "type": "rendered", "raw": "Add test .woodpecker.yml\n", "markup": "markdown", "html": "

Add test .woodpecker.yml

" }, "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/a51241ae1f00cbe728930db48e890b18fd527f99" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/commits/a51241ae1f00cbe728930db48e890b18fd527f99" } }, "parents": [], "rendered": {}, "properties": {} }, "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/branches/main" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits/main" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/branch/main" } }, "type": "branch", "merge_strategies": ["merge_commit", "squash", "fast_forward"], "default_merge_strategy": "merge_commit" }, "new": { "name": "main", "target": { "type": "commit", "hash": "c14c1bb05dfb1fdcdf06b31485fff61b0ea44277", "date": "2022-09-07T20:19:25+00:00", "author": { "type": "author", "raw": "Martin Herren ", "user": { "display_name": "Martin Herren", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D" }, "avatar": { "href": "https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png" }, "html": { "href": "https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/" } }, "type": "user", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "account_id": "5cf8e3a9678ca90f8e7cc8a8", "nickname": "Martin Herren" } }, "message": "a\n", "summary": { "type": "rendered", "raw": "a\n", "markup": "markdown", "html": "

a

" }, "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/commits/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277" } }, "parents": [ { "type": "commit", "hash": "a51241ae1f00cbe728930db48e890b18fd527f99", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/a51241ae1f00cbe728930db48e890b18fd527f99" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/commits/a51241ae1f00cbe728930db48e890b18fd527f99" } } } ], "rendered": {}, "properties": {} }, "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/branches/main" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits/main" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/branch/main" } }, "type": "branch", "merge_strategies": ["merge_commit", "squash", "fast_forward"], "default_merge_strategy": "merge_commit" }, "truncated": false, "created": false, "forced": false, "closed": false, "links": { "commits": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits?include=c14c1bb05dfb1fdcdf06b31485fff61b0ea44277&exclude=a51241ae1f00cbe728930db48e890b18fd527f99" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/diff/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277..a51241ae1f00cbe728930db48e890b18fd527f99" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/branches/compare/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277..a51241ae1f00cbe728930db48e890b18fd527f99" } }, "commits": [ { "type": "commit", "hash": "c14c1bb05dfb1fdcdf06b31485fff61b0ea44277", "date": "2022-09-07T20:19:25+00:00", "author": { "type": "author", "raw": "Martin Herren ", "user": { "display_name": "Martin Herren", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D" }, "avatar": { "href": "https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png" }, "html": { "href": "https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/" } }, "type": "user", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "account_id": "5cf8e3a9678ca90f8e7cc8a8", "nickname": "Martin Herren" } }, "message": "a\n", "summary": { "type": "rendered", "raw": "a\n", "markup": "markdown", "html": "

a

" }, "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/commits/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277" }, "diff": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/diff/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277" }, "approve": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277/approve" }, "comments": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277/comments" }, "statuses": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277/statuses" }, "patch": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/patch/c14c1bb05dfb1fdcdf06b31485fff61b0ea44277" } }, "parents": [ { "type": "commit", "hash": "a51241ae1f00cbe728930db48e890b18fd527f99", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commit/a51241ae1f00cbe728930db48e890b18fd527f99" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo/commits/a51241ae1f00cbe728930db48e890b18fd527f99" } } } ], "rendered": {}, "properties": {} } ] } ] } } ================================================ FILE: server/forge/bitbucket/fixtures/handler.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( "fmt" "net/http" "github.com/gin-gonic/gin" ) // Handler returns an http.Handler that is capable of handling a variety of mock // Bitbucket requests and returning mock responses. func Handler() http.Handler { gin.SetMode(gin.TestMode) e := gin.New() e.POST("/site/oauth2/access_token", getOauth) e.GET("/2.0/user/workspaces/", getWorkspaces) e.GET("/2.0/repositories/:owner/:name", getRepo) e.GET("/2.0/repositories/:owner/:name/hooks", getRepoHooks) e.GET("/2.0/repositories/:owner/:name/src/:commit/:file", getRepoFile) e.DELETE("/2.0/repositories/:owner/:name/hooks/:hook", deleteRepoHook) e.POST("/2.0/repositories/:owner/:name/hooks", createRepoHook) e.POST("/2.0/repositories/:owner/:name/commit/:commit/statuses/build", createRepoStatus) e.GET("/2.0/repositories/:owner", getUserRepos) e.GET("/2.0/user/", getUser) e.GET("/2.0/user/emails", getEmails) e.GET("/2.0/user/workspaces/:workspace/permissions/repositories", getPermissions) e.GET("/2.0/repositories/:owner/:name/commits/:commit", getBranchHead) e.GET("/2.0/repositories/:owner/:name/pullrequests", getPullRequests) e.GET("/2.0/repositories/:owner/:name/diffstat/:commit", getCommitDiffstat) return e } func getCommitDiffstat(c *gin.Context) { c.String(http.StatusOK, diffStatPayload) } func getOauth(c *gin.Context) { if c.PostForm("error") == "invalid_scope" { c.String(http.StatusInternalServerError, "") } switch c.PostForm("code") { case "code_bad_request": c.String(http.StatusInternalServerError, "") return case "code_user_not_found": c.String(http.StatusOK, tokenNotFoundPayload) return } switch c.PostForm("refresh_token") { case "refresh_token_not_found": c.String(http.StatusNotFound, "") case "refresh_token_is_empty": c.Header("Content-Type", "application/json") c.String(http.StatusOK, "{}") default: c.Header("Content-Type", "application/json") c.String(http.StatusOK, tokenPayload) } } func getWorkspaces(c *gin.Context) { switch c.Request.Header.Get("Authorization") { case "Bearer teams_not_found", "Bearer c81e728d": c.String(http.StatusNotFound, "") default: if c.Query("page") == "" || c.Query("page") == "1" { c.String(http.StatusOK, workspacesPayload) } else { c.String(http.StatusOK, "{\"values\":[]}") } } } func getRepo(c *gin.Context) { switch c.Param("name") { case "not_found", "repo_unknown", "repo_not_found": c.String(http.StatusNotFound, "") case "permission_read", "permission_write", "permission_admin": c.String(http.StatusOK, fmt.Sprintf(permissionRepoPayload, c.Param("name"))) case "{898477b2-a080-4089-b385-597a783db392}": c.String(http.StatusOK, repoPayloadFromHook) default: c.String(http.StatusOK, repoPayload) } } func getRepoHooks(c *gin.Context) { switch c.Param("name") { case "hooks_not_found", "repo_no_hooks": c.String(http.StatusNotFound, "") case "hook_empty": c.String(http.StatusOK, "{}") default: if c.Query("page") == "" || c.Query("page") == "1" { c.String(http.StatusOK, repoHookPayload) } else { c.String(http.StatusOK, "{\"values\":[]}") } } } func getRepoFile(c *gin.Context) { switch c.Param("file") { case "dir": c.String(http.StatusOK, repoDirPayload) case "dir_not_found": c.String(http.StatusNotFound, "") case "file_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoFilePayload) } } func getBranchHead(c *gin.Context) { switch c.Param("commit") { case "branch_name": c.String(http.StatusOK, branchCommitsPayload) default: c.String(http.StatusNotFound, "") } } func getPullRequests(c *gin.Context) { switch c.Param("name") { case "repo_name": c.String(http.StatusOK, pullRequestsPayload) default: c.String(http.StatusNotFound, "") } } func createRepoStatus(c *gin.Context) { switch c.Param("name") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, "") } } func createRepoHook(c *gin.Context) { c.String(http.StatusOK, "") } func deleteRepoHook(c *gin.Context) { switch c.Param("name") { case "hook_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, "") } } func getUser(c *gin.Context) { switch c.Request.Header.Get("Authorization") { case "Bearer user_not_found", "Bearer a87ff679": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, userPayload) } } func getEmails(c *gin.Context) { switch c.Request.Header.Get("Authorization") { case "Bearer user_not_found", "Bearer a87ff679": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, emailsPayload) } } func getUserRepos(c *gin.Context) { switch c.Request.Header.Get("Authorization") { case "Bearer repos_not_found", "Bearer 70efdf2e": c.String(http.StatusNotFound, "") default: if c.Query("page") == "" || c.Query("page") == "1" { c.String(http.StatusOK, userRepoPayload) } else { c.String(http.StatusOK, "{\"values\":[]}") } } } func getPermissions(c *gin.Context) { workspace := c.Param("workspace") q := c.Query("q") if c.Query("page") == "" || c.Query("page") == "1" { switch workspace { case "test_name": // Handle query for specific repo (new GetPermission format) if q == "repository.full_name=\"test_name/repo_name\"" { c.String(http.StatusOK, permissionPayLoad) return } // Handle listing all permissions (ListPermissionsAll) if q == "" { c.String(http.StatusOK, permissionsPayLoad) return } case "martinherren1984": // Handle hook test cases if q == "repository.full_name=\"martinherren1984/publictestrepo\"" { c.String(http.StatusOK, permissionHookPayLoad) return } } } c.String(http.StatusOK, "{\"values\":[]}") } const tokenPayload = ` { "access_token":"2YotnFZFEjr1zCsicMWpAA", "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", "token_type":"Bearer", "expires_in":3600 } ` const tokenNotFoundPayload = ` { "access_token":"user_not_found", "refresh_token":"user_not_found", "token_type":"Bearer", "expires_in":3600 } ` const repoPayload = ` { "full_name": "test_name/repo_name", "scm": "git", "is_private": true } ` const permissionRepoPayload = ` { "full_name": "test_name/%s", "scm": "git", "is_private": true } ` const repoPayloadFromHook = ` { "type": "repository", "full_name": "martinherren1984/publictestrepo", "links": { "self": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo" }, "html": { "href": "https://bitbucket.org/martinherren1984/publictestrepo" }, "avatar": { "href": "https://bytebucket.org/ravatar/%7B898477b2-a080-4089-b385-597a783db392%7D?ts=default" }, "pullrequests": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/pullrequests" }, "commits": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/commits" }, "forks": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/forks" }, "watchers": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/watchers" }, "branches": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/branches" }, "tags": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/refs/tags" }, "downloads": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/downloads" }, "source": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/src" }, "clone": [ { "name": "https", "href": "https://bitbucket.org/martinherren1984/publictestrepo.git" }, { "name": "ssh", "href": "git@bitbucket.org:martinherren1984/publictestrepo.git" } ], "hooks": { "href": "https://api.bitbucket.org/2.0/repositories/martinherren1984/publictestrepo/hooks" } }, "name": "PublicTestRepo", "slug": "publictestrepo", "description": "", "scm": "git", "website": null, "owner": { "display_name": "Martin Herren", "links": { "self": { "href": "https://api.bitbucket.org/2.0/users/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D" }, "avatar": { "href": "https://secure.gravatar.com/avatar/37de364488b2ec474b5458ca86442bbb?d=https%3A%2F%2Favatar-management--avatars.us-west-2.prod.public.atl-paas.net%2Finitials%2FMH-2.png" }, "html": { "href": "https://bitbucket.org/%7Bc5a0d676-fd27-4bd4-ac69-a7540d7b495b%7D/" } }, "type": "user", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "account_id": "5cf8e3a9678ca90f8e7cc8a8", "nickname": "Martin Herren" }, "workspace": { "type": "workspace", "uuid": "{c5a0d676-fd27-4bd4-ac69-a7540d7b495b}", "name": "Martin Herren", "slug": "martinherren1984", "links": { "avatar": { "href": "https://bitbucket.org/workspaces/martinherren1984/avatar/?ts=1658761964" }, "html": { "href": "https://bitbucket.org/martinherren1984/" }, "self": { "href": "https://api.bitbucket.org/2.0/workspaces/martinherren1984" } } }, "is_private": false, "project": { "type": "project", "key": "PUB", "uuid": "{2cede481-f59e-49ec-88d0-a85629b7925d}", "name": "PublicTestProject", "links": { "self": { "href": "https://api.bitbucket.org/2.0/workspaces/martinherren1984/projects/PUB" }, "html": { "href": "https://bitbucket.org/martinherren1984/workspace/projects/PUB" }, "avatar": { "href": "https://bitbucket.org/martinherren1984/workspace/projects/PUB/avatar/32?ts=1658768453" } } }, "fork_policy": "allow_forks", "created_on": "2022-07-25T17:01:20.950706+00:00", "updated_on": "2022-09-07T20:19:30.622886+00:00", "size": 85955, "language": "", "uuid": "{898477b2-a080-4089-b385-597a783db392}", "mainbranch": { "name": "master", "type": "branch" }, "override_settings": { "default_merge_strategy": true, "branching_model": true }, "parent": null, "enforced_signed_commits": null, "has_issues": false, "has_wiki": false } ` const repoHookPayload = ` { "pagelen": 10, "values": [ { "uuid": "{afe61e14-2c5f-49e8-8b68-ad1fb55fc052}", "url": "http://127.0.0.1" } ], "page": 1, "size": 1 } ` const repoFilePayload = "dummy payload" const repoDirPayload = ` { "pagelen": 10, "page": 1, "values": [ { "path": "README.md", "type": "commit_file" }, { "path": "test", "type": "commit_directory" }, { "path": ".gitignore", "type": "commit_file" } ] } ` const branchCommitsPayload = ` { "values": [ { "hash": "branch_head_name", "links": { "html": { "href": "https://bitbucket.org/commitlink" } } }, { "hash": "random1" }, { "hash": "random2" } ] } ` const pullRequestsPayload = ` { "values": [ { "id": 123, "title": "PRs title" }, { "id": 456, "title": "Another PRs title" } ], "pagelen": 10, "size": 2, "page": 1 } ` const diffStatPayload = ` { "values": [ { "old": { "path": "main.go" }, "new": { "path": "main.go" } } ] } ` const userPayload = ` { "uuid": "{4d8c0f46-cd62-4b77-b0cf-faa3e4d932c6}", "username": "superman", "links": { "avatar": { "href": "http:\/\/i.imgur.com\/ZygP55A.jpg" } }, "type": "user" } ` const userRepoPayload = ` { "page": 1, "pagelen": 10, "size": 1, "values": [ { "links": { "avatar": { "href": "http:\/\/i.imgur.com\/ZygP55A.jpg" } }, "full_name": "test_name/repo_name", "scm": "git", "is_private": true } ] } ` const emailsPayload = ` { "pagelen": 10, "values": [ { "email": "test@example.com", "is_confirmed": true, "is_primary": true } ], "page": 1, "size": 1 } ` const workspacesPayload = ` { "page": 1, "pagelen": 100, "size": 1, "values": [ { "type": "workspace_access", "administrator": true, "workspace": { "type": "workspace_base", "uuid": "{c7a04a76-fa20-43e4-dc42-a7506db4c95b}", "slug": "test_name", "links": { "avatar": { "href": "https://bitbucket.org/workspaces/ueberdev42/avatar/?ts=1658761964" }, "self": { "href": "https://api.bitbucket.org/2.0/workspaces/ueberdev42" } } } } ] } ` const permissionsPayLoad = ` { "pagelen": 100, "page": 1, "values": [ { "repository": { "full_name": "test_name/repo_name" }, "permission": "read" }, { "repository": { "full_name": "test_name/permission_read" }, "permission": "read" }, { "repository": { "full_name": "test_name/permission_write" }, "permission": "write" }, { "repository": { "full_name": "test_name/permission_admin" }, "permission": "admin" } ] } ` const permissionPayLoad = ` { "pagelen": 100, "page": 1, "values": [ { "repository": { "full_name": "test_name/repo_name" }, "permission": "read" } ] } ` const permissionHookPayLoad = ` { "pagelen": 100, "page": 1, "values": [ { "repository": { "full_name": "martinherren1984/publictestrepo" }, "permission": "admin" } ] } ` ================================================ FILE: server/forge/bitbucket/fixtures/hooks.go ================================================ // Copyright 2018 Drone.IO Inc. // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import _ "embed" //go:embed HookPush.json var HookPush string const HookPushEmptyHash = ` { "push": { "changes": [ { "new": { "type": "branch", "target": { "hash": "" } } } ] } } ` //go:embed HookPull.json var HookPull string //go:embed HookPullRequestMerged.json var HookPullRequestMerged string //go:embed HookPullRequestDeclined.json var HookPullRequestDeclined string ================================================ FILE: server/forge/bitbucket/internal/client.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "golang.org/x/oauth2" "golang.org/x/oauth2/bitbucket" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( pathUser = "%s/2.0/user/" pathEmails = "%s/2.0/user/emails" pathPermissions = "%s/2.0/user/workspaces/%s/permissions/repositories?%s" pathWorkspaces = "%s/2.0/user/workspaces/?%s" pathWorkspace = "%s/2.0/workspaces/%s" pathRepo = "%s/2.0/repositories/%s/%s" pathRepos = "%s/2.0/repositories/%s?%s" pathHook = "%s/2.0/repositories/%s/%s/hooks/%s" pathHooks = "%s/2.0/repositories/%s/%s/hooks?%s" pathSource = "%s/2.0/repositories/%s/%s/src/%s/%s" pathStatus = "%s/2.0/repositories/%s/%s/commit/%s/statuses/build" pathBranches = "%s/2.0/repositories/%s/%s/refs/branches?%s" pathOrgPerms = "%s/2.0/workspaces/%s/permissions?%s" pathPullRequests = "%s/2.0/repositories/%s/%s/pullrequests?%s" pathBranchCommits = "%s/2.0/repositories/%s/%s/commits/%s" pathDir = "%s/2.0/repositories/%s/%s/src/%s/%s" pathDiffStat = "%s/2.0/repositories/%s/%s/diffstat/%s?%s" pageSize = 100 ) type Client struct { *http.Client base string ctx context.Context } func NewClient(ctx context.Context, url string, client *http.Client) *Client { return &Client{ Client: client, base: url, ctx: ctx, } } func NewClientToken(ctx context.Context, url, client, secret string, token *oauth2.Token) *Client { config := &oauth2.Config{ ClientID: client, ClientSecret: secret, Endpoint: bitbucket.Endpoint, } return NewClient(ctx, url, config.Client(ctx, token)) } func (c *Client) FindCurrent() (*Account, error) { out := new(Account) uri := fmt.Sprintf(pathUser, c.base) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) ListEmail() (*EmailResp, error) { out := new(EmailResp) uri := fmt.Sprintf(pathEmails, c.base) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) ListWorkspaces(opts *ListOpts) (*WorkspacesResp, error) { out := new(WorkspacesResp) uri := fmt.Sprintf(pathWorkspaces, c.base, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) FindRepo(owner, name string) (*Repo, error) { out := new(Repo) uri := fmt.Sprintf(pathRepo, c.base, owner, name) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) ListRepos(workspace string, opts *ListOpts) (*RepoResp, error) { out := new(RepoResp) uri := fmt.Sprintf(pathRepos, c.base, workspace, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) ListReposAll(workspace string) ([]*Repo, error) { return shared_utils.Paginate(func(page int) ([]*Repo, error) { resp, err := c.ListRepos(workspace, &ListOpts{Page: page, PageLen: pageSize}) if err != nil { return nil, err } return resp.Values, nil }, -1) } func (c *Client) FindHook(owner, name, id string) (*Hook, error) { out := new(Hook) uri := fmt.Sprintf(pathHook, c.base, owner, name, id) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) ListHooks(owner, name string, opts *ListOpts) (*HookResp, error) { out := new(HookResp) uri := fmt.Sprintf(pathHooks, c.base, owner, name, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) CreateHook(owner, name string, hook *Hook) error { uri := fmt.Sprintf(pathHooks, c.base, owner, name, "") _, err := c.do(uri, http.MethodPost, hook, nil) return err } func (c *Client) DeleteHook(owner, name, id string) error { uri := fmt.Sprintf(pathHook, c.base, owner, name, id) _, err := c.do(uri, http.MethodDelete, nil, nil) return err } func (c *Client) FindSource(owner, name, revision, path string) (*string, error) { uri := fmt.Sprintf(pathSource, c.base, owner, name, revision, path) return c.do(uri, http.MethodGet, nil, nil) } func (c *Client) CreateStatus(owner, name, revision string, status *PipelineStatus) error { uri := fmt.Sprintf(pathStatus, c.base, owner, name, revision) _, err := c.do(uri, http.MethodPost, status, nil) return err } func (c *Client) GetPermission(owner, fullName string) (*RepoPerm, error) { out := new(RepoPermResp) uri := fmt.Sprintf(pathPermissions, c.base, owner, fmt.Sprintf("q=%s", url.QueryEscape(fmt.Sprintf("repository.full_name=%q", fullName)))) _, err := c.do(uri, http.MethodGet, nil, out) if err != nil { return nil, err } if len(out.Values) == 0 { return nil, fmt.Errorf("no permissions in repository %s", fullName) } return out.Values[0], nil } func (c *Client) ListPermissions(workspace string, opts *ListOpts) (*RepoPermResp, error) { out := new(RepoPermResp) uri := fmt.Sprintf(pathPermissions, c.base, workspace, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) ListPermissionsAll(workspace string) ([]*RepoPerm, error) { return shared_utils.Paginate(func(page int) ([]*RepoPerm, error) { resp, err := c.ListPermissions(workspace, &ListOpts{Page: page, PageLen: pageSize}) if err != nil { return nil, err } return resp.Values, nil }, -1) } func (c *Client) ListBranches(owner, name string, opts *ListOpts) ([]*Branch, error) { out := new(BranchResp) uri := fmt.Sprintf(pathBranches, c.base, owner, name, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) return out.Values, err } func (c *Client) GetBranchHead(owner, name, branch string) (*Commit, error) { out := new(CommitsResp) uri := fmt.Sprintf(pathBranchCommits, c.base, owner, name, branch) _, err := c.do(uri, http.MethodGet, nil, out) if err != nil { return nil, err } if len(out.Values) == 0 { return nil, fmt.Errorf("no commits in branch %s", branch) } return out.Values[0], nil } func (c *Client) GetUserWorkspaceMembership(workspace, user string) (string, error) { out := new(WorkspaceMembershipResp) opts := &ListOpts{Page: 1, PageLen: pageSize} for { uri := fmt.Sprintf(pathOrgPerms, c.base, workspace, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) if err != nil { return "", err } for _, m := range out.Values { if m.User.Nickname == user { return m.Permission, nil } } if len(out.Next) == 0 { break } opts.Page++ } return "", nil } func (c *Client) ListPullRequests(owner, name string, opts *ListOpts) ([]*PullRequest, error) { out := new(PullRequestResp) uri := fmt.Sprintf(pathPullRequests, c.base, owner, name, opts.Encode()) _, err := c.do(uri, http.MethodGet, nil, out) return out.Values, err } func (c *Client) ListChangedFiles(owner, name, ref string) (result []string, err error) { paths := make(map[string]struct{}) opts := &ListOpts{Page: 1, PageLen: pageSize} for { var resp DiffStatResp uri := fmt.Sprintf(pathDiffStat, c.base, owner, name, ref, opts.Encode()) if _, err = c.do(uri, http.MethodGet, nil, &resp); err != nil { return nil, err } for _, diff := range resp.Values { if diff == nil { continue } if diff.Old != nil { paths[diff.Old.Path] = struct{}{} } if diff.New != nil { paths[diff.New.Path] = struct{}{} } } if resp.Next == nil { break } opts.Page++ } result = make([]string, 0, len(paths)) for path := range paths { result = append(result, path) } return result, err } func (c *Client) GetWorkspace(name string) (*Workspace, error) { out := new(Workspace) uri := fmt.Sprintf(pathWorkspace, c.base, name) _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) GetRepoFiles(owner, name, revision, path string, page *string) (*DirResp, error) { out := new(DirResp) uri := fmt.Sprintf(pathDir, c.base, owner, name, revision, path) if page != nil { uri += "?page=" + *page } _, err := c.do(uri, http.MethodGet, nil, out) return out, err } func (c *Client) do(rawURL, method string, in, out any) (*string, error) { uri, err := url.Parse(rawURL) if err != nil { return nil, err } // if we are posting or putting data, we need to // write it to the body of the request. var buf io.ReadWriter if in != nil { buf = new(bytes.Buffer) err := json.NewEncoder(buf).Encode(in) if err != nil { return nil, err } } // creates a new http request to bitbucket. req, err := http.NewRequestWithContext(c.ctx, method, uri.String(), buf) if err != nil { return nil, err } if in != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // if an error is encountered, parse and return the // error response. if resp.StatusCode > http.StatusPartialContent { err := Error{} _ = json.NewDecoder(resp.Body).Decode(&err) err.Status = resp.StatusCode return nil, err } // if a json response is expected, parse and return // the json response. if out != nil { return nil, json.NewDecoder(resp.Body).Decode(out) } bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, err } bodyString := string(bodyBytes) return &bodyString, nil } ================================================ FILE: server/forge/bitbucket/internal/types.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package internal import ( "net/url" "strconv" "time" ) // cspell:words pagelen type Account struct { UUID string `json:"uuid"` Login string `json:"username"` Name string `json:"display_name"` Type string `json:"type"` Links Links `json:"links"` } type Workspace struct { UUID string `json:"uuid"` Slug string `json:"slug"` Type string `json:"type"` Links Links `json:"links"` } type WorkspaceAccess struct { Type string `json:"type"` Administrator bool `json:"administrator"` Workspace *Workspace `json:"workspace"` } type WorkspacesResp struct { Page int `json:"page"` Pages int `json:"pagelen"` Size int `json:"size"` Next string `json:"next"` Values []*WorkspaceAccess `json:"values"` } type PipelineStatus struct { State string `json:"state"` Key string `json:"key"` Name string `json:"name,omitempty"` URL string `json:"url"` Desc string `json:"description,omitempty"` Refname string `json:"refname,omitempty"` } type Email struct { Email string `json:"email"` IsConfirmed bool `json:"is_confirmed"` IsPrimary bool `json:"is_primary"` } type EmailResp struct { Page int `json:"page"` Pages int `json:"pagelen"` Size int `json:"size"` Next string `json:"next"` Values []*Email `json:"values"` } type Hook struct { UUID string `json:"uuid,omitempty"` Desc string `json:"description"` URL string `json:"url"` Events []string `json:"events"` Active bool `json:"active"` } type HookResp struct { Page int `json:"page"` Pages int `json:"pagelen"` Size int `json:"size"` Next string `json:"next"` Values []*Hook `json:"values"` } type Links struct { Self Link `json:"self"` Avatar Link `json:"avatar"` HTML Link `json:"html"` Clone []Link `json:"clone"` } type Link struct { Href string `json:"href"` Name string `json:"name"` } type LinkClone struct { Link } type Repo struct { UUID string `json:"uuid"` Owner Account `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` Language string `json:"language"` IsPrivate bool `json:"is_private"` Scm string `json:"scm"` Desc string `json:"desc"` Links Links `json:"links"` MainBranch struct { Type string `json:"type"` Name string `json:"name"` } `json:"mainbranch"` // cspell:ignore mainbranch } type RepoResp struct { Page int `json:"page"` Pages int `json:"pagelen"` Size int `json:"size"` Next string `json:"next"` Values []*Repo `json:"values"` } type Change struct { New struct { Type string `json:"type"` Name string `json:"name"` Target struct { Type string `json:"type"` Hash string `json:"hash"` Message string `json:"message"` Date time.Time `json:"date"` Links Links `json:"links"` Author struct { Raw string `json:"raw"` User Account `json:"user"` } `json:"author"` } `json:"target"` } `json:"new"` } type PushHook struct { Actor Account `json:"actor"` Repo Repo `json:"repository"` Push struct { Changes []Change `json:"changes"` } `json:"push"` } type PullRequestHook struct { Actor Account `json:"actor"` Repo Repo `json:"repository"` PullRequest struct { ID int `json:"id"` Type string `json:"type"` Reason string `json:"reason"` Desc string `json:"description"` Title string `json:"title"` State string `json:"state"` Links Links `json:"links"` Created time.Time `json:"created_on"` Updated time.Time `json:"updated_on"` MergeCommit struct { Hash string `json:"hash"` } `json:"merge_commit"` Source struct { Repo Repo `json:"repository"` Commit struct { Hash string `json:"hash"` Links Links `json:"links"` } `json:"commit"` Branch struct { Name string `json:"name"` } `json:"branch"` } `json:"source"` Dest struct { Repo Repo `json:"repository"` Commit struct { Hash string `json:"hash"` Links Links `json:"links"` } `json:"commit"` Branch struct { Name string `json:"name"` } `json:"branch"` } `json:"destination"` } `json:"pullrequest"` } type WorkspaceMembershipResp struct { Page int `json:"page"` Pages int `json:"pagelen"` Size int `json:"size"` Next string `json:"next"` Values []struct { Permission string `json:"permission"` User struct { Nickname string `json:"nickname"` } } `json:"values"` } type ListOpts struct { Page int PageLen int } func (o *ListOpts) Encode() string { params := url.Values{} if o.Page != 0 { params.Set("page", strconv.Itoa(o.Page)) } if o.PageLen != 0 { params.Set("pagelen", strconv.Itoa(o.PageLen)) } return params.Encode() } type Error struct { Status int Body struct { Message string `json:"message"` } `json:"error"` } func (e Error) Error() string { return e.Body.Message } type RepoPermResp struct { Page int `json:"page"` Pages int `json:"pagelen"` Values []*RepoPerm `json:"values"` } type RepoPerm struct { Permission string `json:"permission"` Repo Repo `json:"repository"` } type BranchResp struct { Values []*Branch `json:"values"` } type Branch struct { Name string `json:"name"` } type PullRequestResp struct { Page uint `json:"page"` PageLen uint `json:"pagelen"` Size uint `json:"size"` Values []*PullRequest `json:"values"` } type PullRequest struct { ID uint `json:"id"` Title string `json:"title"` } type CommitsResp struct { Values []*Commit `json:"values"` } type Commit struct { Hash string `json:"hash"` Links struct { HTML struct { Href string `json:"href"` } `json:"html"` } `json:"links"` } type DirResp struct { Page uint `json:"page"` PageLen uint `json:"pagelen"` Next *string `json:"next"` Values []*Dir `json:"values"` } type Dir struct { Path string `json:"path"` Type string `json:"type"` Size uint `json:"size"` } type DiffStatResp struct { Next *string `json:"next"` Values []*Diff `json:"values"` } type Diff struct { Old *DiffFile `json:"old"` New *DiffFile `json:"new"` } type DiffFile struct { Path string `json:"path"` } ================================================ FILE: server/forge/bitbucket/parse.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucket import ( "encoding/json" "io" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/internal" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( hookEvent = "X-Event-Key" hookPush = "repo:push" hookPullCreated = "pullrequest:created" hookPullUpdated = "pullrequest:updated" hookPullMerged = "pullrequest:fulfilled" hookPullDeclined = "pullrequest:rejected" stateClosed = "MERGED" stateDeclined = "DECLINED" ) // parseHook parses a Bitbucket hook from an http.Request request and returns Pull Request, // Repo and Pipeline detail. If a hook type is unsupported nil values are returned. func parseHook(r *http.Request) (*internal.PullRequestHook, *model.Repo, *model.Pipeline, error) { payload, err := io.ReadAll(r.Body) if err != nil { return nil, nil, nil, err } hookType := r.Header.Get(hookEvent) switch hookType { case hookPush: r, pl, err := parsePushHook(payload) return nil, r, pl, err case hookPullCreated, hookPullUpdated, hookPullMerged, hookPullDeclined: return parsePullHook(payload) default: return nil, nil, nil, &types.ErrIgnoreEvent{Event: hookType} } } // parsePushHook parses a push hook and returns the Repo and Pipeline details. // If the commit type is unsupported it returns an ErrIgnoreEvent error. func parsePushHook(payload []byte) (*model.Repo, *model.Pipeline, error) { hook := internal.PushHook{} err := json.Unmarshal(payload, &hook) if err != nil { return nil, nil, err } for _, change := range hook.Push.Changes { if change.New.Target.Hash == "" { continue } return convertRepo(&hook.Repo, &internal.RepoPerm{}), convertPushHook(&hook, &change), nil } return nil, nil, &types.ErrIgnoreEvent{Event: "push", Reason: "BB reports no Changes"} } // parsePullHook parses a pull request hook and returns the Pull Request, Repo and Pipeline // details. func parsePullHook(payload []byte) (*internal.PullRequestHook, *model.Repo, *model.Pipeline, error) { hook := internal.PullRequestHook{} if err := json.Unmarshal(payload, &hook); err != nil { return nil, nil, nil, err } return &hook, convertRepo(&hook.Repo, &internal.RepoPerm{}), convertPullHook(&hook), nil } ================================================ FILE: server/forge/bitbucket/parse_test.go ================================================ // // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucket import ( "bytes" "net/http" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_parseHook(t *testing.T) { t.Run("unsupported hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, "issue:created") _, r, b, err := parseHook(req) assert.Nil(t, r) assert.Nil(t, b) assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) }) t.Run("malformed pull-request hook", func(t *testing.T) { buf := bytes.NewBufferString("[]") req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPullCreated) _, _, _, err := parseHook(req) assert.Error(t, err) }) t.Run("pull-request", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPull) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPullCreated) pr, r, b, err := parseHook(req) assert.NoError(t, err) assert.NotNil(t, pr) assert.Equal(t, "user_name/repo_name", r.FullName) assert.Equal(t, model.EventPull, b.Event) assert.Equal(t, "d3022fc0ca3d", b.Commit) }) t.Run("pull-request merged", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequestMerged) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPullMerged) pr, r, b, err := parseHook(req) assert.NoError(t, err) assert.NotNil(t, pr) assert.Equal(t, "anbraten/test-2", r.FullName) assert.Equal(t, model.EventPullClosed, b.Event) assert.Equal(t, "006704dbeab2", b.Commit) }) t.Run("pull-request closed", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequestDeclined) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPullDeclined) pr, r, b, err := parseHook(req) assert.NoError(t, err) assert.NotNil(t, pr) assert.Equal(t, "anbraten/test-2", r.FullName) assert.Equal(t, model.EventPullClosed, b.Event) assert.Equal(t, "f90e18fc9d45", b.Commit) }) t.Run("malformed push", func(t *testing.T) { buf := bytes.NewBufferString("[]") req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPush) _, _, _, err := parseHook(req) assert.Error(t, err) }) t.Run("missing commit sha", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPushEmptyHash) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPush) _, r, b, err := parseHook(req) assert.Nil(t, r) assert.Nil(t, b) assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) }) t.Run("push hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPush) pr, r, b, err := parseHook(req) assert.NoError(t, err) assert.Nil(t, pr) assert.Equal(t, "martinherren1984/publictestrepo", r.FullName) assert.Equal(t, "https://bitbucket.org/martinherren1984/publictestrepo", r.Clone) assert.Equal(t, "c14c1bb05dfb1fdcdf06b31485fff61b0ea44277", b.Commit) assert.Equal(t, "a\n", b.Message) }) } ================================================ FILE: server/forge/bitbucketdatacenter/bitbucketdatacenter.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucketdatacenter import ( "context" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/neticdk/go-bitbucket/bitbucket" "github.com/rs/zerolog/log" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter/internal" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" ) const ( listLimit = 250 millisecondsInSecond = 1000 ) // Opts defines configuration options. type Opts struct { URL string // Bitbucket server url for API access. Username string // Git machine account username. Password string // Git machine account password. OAuthClientID string // OAuth 2.0 client id OAuthClientSecret string // OAuth 2.0 client secret OAuthHost string // OAuth 2.0 host OAuthEnableProjectAdminScope bool // Whether to enable project admin scope. Should be set as default in the next major version. } type client struct { forgeID int64 url string urlAPI string clientID string clientSecret string oauthHost string username string password string oauthEnableProjectAdminScope bool } // New returns a Forge implementation that integrates with Bitbucket DataCenter/Server, // the on-premise edition of Bitbucket Cloud, formerly known as Stash. func New(id int64, opts Opts) (forge.Forge, error) { config := &client{ forgeID: id, url: opts.URL, urlAPI: fmt.Sprintf("%s/rest", opts.URL), clientID: opts.OAuthClientID, clientSecret: opts.OAuthClientSecret, oauthHost: opts.OAuthHost, username: opts.Username, password: opts.Password, oauthEnableProjectAdminScope: opts.OAuthEnableProjectAdminScope, } switch { case opts.Username == "": return nil, fmt.Errorf("must have a git machine account username") case opts.Password == "": return nil, fmt.Errorf("must have a git machine account password") case opts.OAuthClientID == "": return nil, fmt.Errorf("must have an oauth 2.0 client id") case opts.OAuthClientSecret == "": return nil, fmt.Errorf("must have an oauth 2.0 client secret") } return config, nil } // Name returns the string name of this driver. func (c *client) Name() string { return "bitbucket_dc" } // URL returns the root url of a configured forge. func (c *client) URL() string { return c.url } func (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { config := c.newOAuth2Config() // TODO: Use pkce flow (https://oauth.net/2/pkce/) ... redirectURL := config.AuthCodeURL(req.State) if len(req.Code) == 0 { return nil, redirectURL, nil } token, err := config.Exchange(ctx, req.Code) if err != nil { return nil, redirectURL, err } client := internal.NewClientWithToken(ctx, config.TokenSource(ctx, &oauth2.Token{ AccessToken: token.AccessToken, }), c.url) userSlug, err := client.FindCurrentUser(ctx) if err != nil { return nil, "", err } bc, err := c.newClient(ctx, &model.User{AccessToken: token.AccessToken}) if err != nil { return nil, "", fmt.Errorf("unable to create bitbucket client: %w", err) } user, _, err := bc.Users.GetUser(ctx, userSlug) if err != nil { return nil, "", fmt.Errorf("unable to query for user: %w", err) } u := convertUser(user, c.url) updateUserCredentials(u, token) return u, "", nil } func (c *client) Refresh(ctx context.Context, u *model.User) (bool, error) { config := c.newOAuth2Config() t := &oauth2.Token{ RefreshToken: u.RefreshToken, } ts := config.TokenSource(ctx, t) tok, err := ts.Token() if err != nil { return false, fmt.Errorf("unable to refresh OAuth 2.0 token from bitbucket datacenter: %w", err) } updateUserCredentials(u, tok) return true, nil } func (c *client) Repo(ctx context.Context, u *model.User, rID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } var repo *bitbucket.Repository if rID.IsValid() { opts := &bitbucket.RepositorySearchOptions{Name: name, ProjectKey: owner, Permission: bitbucket.PermissionRepoWrite, ListOptions: bitbucket.ListOptions{Limit: listLimit}} for { repos, resp, err := bc.Projects.SearchRepositories(ctx, opts) if err != nil { return nil, fmt.Errorf("unable to search repositories: %w", err) } for _, r := range repos { if rID == convertID(r.ID) { repo = r break } } if resp.LastPage { break } opts.Start = resp.NextPageStart } if repo == nil { return nil, fmt.Errorf("%w: unable to find repository with id: %s", forge_types.ErrRepoNotFound, rID) } } else { repo, _, err = bc.Projects.GetRepository(ctx, owner, name) if err != nil { return nil, fmt.Errorf("%w: unable to get repository: %w", forge_types.ErrRepoNotFound, err) } } b, _, err := bc.Projects.GetDefaultBranch(ctx, repo.Project.Key, repo.Slug) if err != nil { return nil, fmt.Errorf("unable to fetch default branch: %w", err) } if b.DisplayID == "" { return nil, errors.New("default branch setting does not exist") } perms := &model.Perm{Pull: true, Push: true} _, _, err = bc.Projects.ListWebhooks(ctx, repo.Project.Key, repo.Slug, &bitbucket.ListOptions{}) if err == nil { perms.Admin = true } return convertRepo(repo, perms, b.DisplayID), nil } func (c *client) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { // we do not support pagination as we merge different responses together // so first page returns all and we paginate here if p.Page != 1 { return nil, nil } bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } opts := &bitbucket.RepositorySearchOptions{ Permission: bitbucket.PermissionRepoWrite, ListOptions: bitbucket.ListOptions{Limit: listLimit}, } all := make([]*model.Repo, 0) for { repos, resp, err := bc.Projects.SearchRepositories(ctx, opts) if err != nil { return nil, fmt.Errorf("unable to search repositories: %w", err) } for _, r := range repos { perms := &model.Perm{Pull: true, Push: true, Admin: false} all = append(all, convertRepo(r, perms, "")) } if resp.LastPage { break } opts.Start = resp.NextPageStart } // Add admin permissions to relevant repositories opts = &bitbucket.RepositorySearchOptions{Permission: bitbucket.PermissionRepoAdmin, ListOptions: bitbucket.ListOptions{Limit: listLimit}} for { repos, resp, err := bc.Projects.SearchRepositories(ctx, opts) if err != nil { return nil, fmt.Errorf("unable to search repositories: %w", err) } for _, r := range repos { for i, c := range all { if c.ForgeRemoteID == convertID(r.ID) { all[i].Perm = &model.Perm{Pull: true, Push: true, Admin: true} break } } } if resp.LastPage { break } opts.Start = resp.NextPageStart } return all, nil } func (c *client) File(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]byte, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } b, resp, err := bc.Projects.GetTextFileContent(ctx, r.Owner, r.Name, f, p.Commit) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { // requested directory might not exist return nil, &forge_types.ErrConfigNotFound{ Configs: []string{f}, } } return nil, err } return b, nil } func (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, path string) ([]*forge_types.FileMeta, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } opts := &bitbucket.FilesListOptions{At: p.Commit} all := make([]*forge_types.FileMeta, 0) for { list, resp, err := bc.Projects.ListFiles(ctx, r.Owner, r.Name, path, opts) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { // requested directory might not exist return nil, &forge_types.ErrConfigNotFound{ Configs: []string{path}, } } return nil, err } for _, f := range list { fullPath := fmt.Sprintf("%s/%s", path, f) data, err := c.File(ctx, u, r, p, fullPath) if err != nil { return nil, err } all = append(all, &forge_types.FileMeta{Name: fullPath, Data: data}) } if resp.LastPage { break } opts.Start = resp.NextPageStart } return all, nil } func (c *client) Status(ctx context.Context, u *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { bc, err := c.newClient(ctx, u) if err != nil { return fmt.Errorf("unable to create bitbucket client: %w", err) } status := &bitbucket.BuildStatus{ State: convertStatus(workflow.State), URL: common.GetPipelineStatusURL(repo, pipeline, workflow), Key: common.GetPipelineStatusContext(repo, pipeline, workflow), Description: common.GetPipelineStatusDescription(workflow.State), Duration: uint64((pipeline.Finished - pipeline.Started) * millisecondsInSecond), Parent: common.GetPipelineStatusContext(repo, pipeline, workflow), DateAdded: bitbucket.DateTime(time.Unix(pipeline.Started, 0)), Ref: fmt.Sprintf("refs/heads/%s", pipeline.Branch), } _, err = bc.Projects.CreateBuildStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, status) return err } func (c *client) Netrc(_ *model.User, r *model.Repo) (*model.Netrc, error) { host, err := common.ExtractHostFromCloneURL(r.Clone) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } return &model.Netrc{ Login: c.username, Password: c.password, Machine: host, Type: model.ForgeTypeBitbucketDatacenter, }, nil } // Branches returns the names of all branches for the named repository. func (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } opts := &bitbucket.BranchSearchOptions{ListOptions: convertListOptions(p)} all := make([]string, 0, p.PerPage) for { branches, resp, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, opts) if err != nil { return nil, fmt.Errorf("unable to list branches: %w", err) } for _, b := range branches { all = append(all, b.DisplayID) } if !p.All || resp.LastPage { break } opts.Start = resp.NextPageStart } return all, nil } func (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, b string) (*model.Commit, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } branches, _, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, &bitbucket.BranchSearchOptions{Filter: b}) if err != nil { return nil, err } if len(branches) == 0 { return nil, fmt.Errorf("no matching branches returned") } for _, branch := range branches { if branch.DisplayID == b { return &model.Commit{ SHA: branch.LatestCommit, ForgeURL: fmt.Sprintf("%s/commits/%s", strings.TrimSuffix(r.ForgeURL, "/browse"), branch.LatestCommit), }, nil } } return nil, fmt.Errorf("no matching branches found") } func (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } opts := &bitbucket.PullRequestSearchOptions{ListOptions: convertListOptions(p)} all := make([]*model.PullRequest, 0) for { prs, resp, err := bc.Projects.SearchPullRequests(ctx, r.Owner, r.Name, opts) if err != nil { return nil, fmt.Errorf("unable to list pull-requests: %w", err) } for _, pr := range prs { all = append(all, &model.PullRequest{Index: convertID(pr.ID), Title: pr.Title}) } if !p.All || resp.LastPage { break } opts.Start = resp.NextPageStart } return all, nil } func (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { bc, err := c.newClient(ctx, u) if err != nil { return fmt.Errorf("unable to create bitbucket client: %w", err) } err = c.Deactivate(ctx, u, r, link) if err != nil { return fmt.Errorf("unable to deactivate old webhooks: %w", err) } webhook := &bitbucket.Webhook{ Name: "Woodpecker", URL: link, Events: []bitbucket.EventKey{bitbucket.EventKeyRepoRefsChanged, bitbucket.EventKeyPullRequestFrom, bitbucket.EventKeyPullRequestMerged, bitbucket.EventKeyPullRequestDeclined, bitbucket.EventKeyPullRequestDeleted, bitbucket.EventKeyPullRequestOpened}, Active: true, Config: &bitbucket.WebhookConfiguration{ Secret: r.Hash, }, } _, _, err = bc.Projects.CreateWebhook(ctx, r.Owner, r.Name, webhook) return err } func (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { bc, err := c.newClient(ctx, u) if err != nil { return fmt.Errorf("unable to create bitbucket client: %w", err) } // check repo exists if _, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name); err != nil { return fmt.Errorf("repo online check failed: %w", err) } lu, err := url.Parse(link) if err != nil { return err } opts := &bitbucket.ListOptions{} var ids []uint64 for { hooks, resp, err := bc.Projects.ListWebhooks(ctx, r.Owner, r.Name, opts) if err != nil { return err } for _, h := range hooks { hu, err := url.Parse(h.URL) if err == nil && hu.Host == lu.Host { ids = append(ids, h.ID) } } if resp.LastPage { break } opts.Start = resp.NextPageStart } for _, id := range ids { _, err = bc.Projects.DeleteWebhook(ctx, r.Owner, r.Name, id) if err != nil { return err } } return nil } func (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { hook, currCommit, prevCommit, err := parseHook(r, c.url) if err != nil { return nil, nil, fmt.Errorf("unable to parse hook: %w", err) } user, repo, err := c.getUserAndRepo(ctx, hook.Repo) if err != nil { return nil, nil, fmt.Errorf("failed to get user and repo: %w", err) } err = bitbucket.ValidateSignature(r, hook.Payload, []byte(repo.Hash)) if err != nil { return nil, nil, fmt.Errorf("unable to validate signature on incoming webhook payload: %w", err) } var pipe *model.Pipeline switch e := hook.Event.(type) { case *bitbucket.RepositoryPushEvent: pipe, err = c.updatePipelineFromCommits(ctx, user, repo, hook.Pipeline, currCommit, prevCommit) case *bitbucket.PullRequestEvent: pipe, err = c.updatePipelineFromPullRequest(ctx, user, repo, hook.Pipeline, e.PullRequest.ID) } if err != nil { return nil, nil, fmt.Errorf("failed to update pipeline: %w", err) } if pipe == nil { return nil, nil, nil } return repo, pipe, nil } func (c *client) getUserAndRepo(ctx context.Context, r *model.Repo) (*model.User, *model.Repo, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return nil, nil, fmt.Errorf("unable to get store from context") } repo, err := _store.GetRepoForgeID(c.forgeID, r.ForgeRemoteID) if err != nil { return nil, nil, fmt.Errorf("unable to get repo: %w", err) } log.Trace().Any("repo", repo).Msg("got repo") user, err := _store.GetUser(repo.UserID) if err != nil { return nil, nil, fmt.Errorf("unable to get user: %w", err) } log.Trace().Any("user", user).Msg("got user") forge.Refresh(ctx, c, _store, user) return user, repo, nil } func (c *client) updatePipelineFromCommits(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, currCommit, prevCommit string) (*model.Pipeline, error) { if p == nil { return nil, nil } bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } commit, _, err := bc.Projects.GetCommit(ctx, r.Owner, r.Name, p.Commit) if err != nil { return nil, fmt.Errorf("unable to read commit: %w", err) } // In Bitbucket Data Center, when using annotated tags, the webhook's ToHash is the tag object SHA, not the actual commit SHA. // Update p.Commit so that build statuses are posted to the correct commit SHA. if p.Event == model.EventTag && commit.ID != "" && commit.ID != p.Commit { p.Commit = commit.ID p.ForgeURL = fmt.Sprintf("%s/projects/%s/repos/%s/commits/%s", c.url, r.Owner, r.Name, commit.ID) } p.Message = commit.Message opts := &bitbucket.CompareChangesOptions{} if currCommit != "" { opts.From = currCommit } if prevCommit != "" { opts.To = prevCommit } for { changes, resp, err := bc.Projects.CompareChanges(ctx, r.Owner, r.Name, opts) if err != nil { return nil, fmt.Errorf("unable to list commit changes: %w", err) } for _, ch := range changes { p.ChangedFiles = append(p.ChangedFiles, ch.Path.Title) } if resp.LastPage { break } opts.Start = resp.NextPageStart } return p, nil } func (c *client) updatePipelineFromPullRequest(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, pullRequestID uint64) (*model.Pipeline, error) { bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } opts := &bitbucket.ListOptions{} for { changes, resp, err := bc.Projects.ListPullRequestChanges(ctx, r.Owner, r.Name, pullRequestID, opts) if err != nil { return nil, fmt.Errorf("unable to list changes in pull request: %w", err) } for _, ch := range changes { p.ChangedFiles = append(p.ChangedFiles, ch.Path.Title) } if resp.LastPage { break } opts.Start = resp.NextPageStart } return p, nil } // Teams fetches all the projects for a given user and converts them into teams. func (c *client) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { if p.Page != 1 { return make([]*model.Team, 0), nil } opts := convertListOptions(p) allProjects := make([]*bitbucket.Project, 0) bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create client: %w", err) } for { projects, resp, err := bc.Projects.ListProjects(ctx, &opts) if err != nil { return nil, fmt.Errorf("unable to fetch projects: %w", err) } allProjects = append(allProjects, projects...) if resp.LastPage { break } opts.Start = resp.NextPageStart } return convertProjectsToTeams(allProjects, bc), nil } // TeamPerm is not supported. func (*client) TeamPerm(_ *model.User, _ string) (*model.Perm, error) { return nil, nil } // OrgMembership returns if user is member of organization and if user // is admin/owner in this organization. func (c *client) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) { if !c.oauthEnableProjectAdminScope { // This method cannot be implemented without the PROJECT_ADMIN scope included in the OAuth2 configuration return nil, nil } bc, err := c.newClient(ctx, u) if err != nil { return nil, fmt.Errorf("unable to create bitbucket client: %w", err) } // Check if the user is Bitbucket project admin if c.hasProjectAdminAccess(ctx, bc, org) { return &model.OrgPerm{Member: true, Admin: true}, nil } // User is not Bitbucket project admin, check if they have write access to any repositories in the Bitbucket project. // If they have, they are considered to be an organization member. hasMembership, err := c.hasRepositoryWriteAccess(ctx, org, bc) if err != nil { return nil, fmt.Errorf("failed to check repository access: %w", err) } if hasMembership { return &model.OrgPerm{Member: true, Admin: false}, nil } return &model.OrgPerm{Member: false, Admin: false}, nil } func (c *client) hasProjectAdminAccess(ctx context.Context, client *bitbucket.Client, org string) bool { // If the user can access project permissions, the user has project admin access in the Bitbucket perms, _, err := client.Projects.SearchProjectPermissions(ctx, org, &bitbucket.ProjectPermissionSearchOptions{}) if err == nil && len(perms) > 0 { return true } return false } func (c *client) hasRepositoryWriteAccess(ctx context.Context, org string, client *bitbucket.Client) (bool, error) { opts := &bitbucket.RepositorySearchOptions{ Archived: "ACTIVE", ProjectKey: org, Permission: bitbucket.PermissionRepoWrite, } for { repos, resp, err := client.Projects.SearchRepositories(ctx, opts) if err != nil { return false, fmt.Errorf("failed to search repositories: %w", err) } // If we find any repositories with write access, user has membership if len(repos) > 0 { return true, nil } if resp.LastPage { break } opts.Start = resp.NextPageStart } return false, nil } // Org fetches the organization from the forge by name. If the name is a user an org with type user is returned. func (c *client) Org(_ context.Context, _ *model.User, owner string) (*model.Org, error) { if strings.HasPrefix(owner, "~") { return &model.Org{ Name: owner, IsUser: true, }, nil } return &model.Org{ Name: owner, IsUser: false, }, nil } func (c *client) newOAuth2Config() *oauth2.Config { publicOAuthURL := c.oauthHost if publicOAuthURL == "" { publicOAuthURL = c.urlAPI } scopes := []string{ string(bitbucket.PermissionRepoRead), string(bitbucket.PermissionRepoWrite), string(bitbucket.PermissionRepoAdmin), } // TODO: Remove this feature flag in the next major version and always include project admin scope if c.oauthEnableProjectAdminScope { scopes = append(scopes, string(bitbucket.PermissionProjectAdmin)) } return &oauth2.Config{ ClientID: c.clientID, ClientSecret: c.clientSecret, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("%s/oauth2/latest/authorize", publicOAuthURL), TokenURL: fmt.Sprintf("%s/oauth2/latest/token", c.urlAPI), }, Scopes: scopes, RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), } } func (c *client) newClient(ctx context.Context, u *model.User) (*bitbucket.Client, error) { config := c.newOAuth2Config() t := &oauth2.Token{ AccessToken: u.AccessToken, } client := config.Client(ctx, t) client = httputil.WrapClient(client, "forge-bitbucketdatacenter") return bitbucket.NewClient(c.urlAPI, client) } ================================================ FILE: server/forge/bitbucketdatacenter/bitbucketdatacenter_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucketdatacenter import ( "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestNew(t *testing.T) { forge, err := New(1, Opts{ URL: "http://localhost:8080", Username: "0ZXh0IjoiI", Password: "I1NiIsInR5", OAuthClientID: "client-id", OAuthClientSecret: "client-secret", }) assert.NoError(t, err) assert.NotNil(t, forge) cl, ok := forge.(*client) assert.True(t, ok) assert.Equal(t, &client{ forgeID: 1, url: "http://localhost:8080", urlAPI: "http://localhost:8080/rest", username: "0ZXh0IjoiI", password: "I1NiIsInR5", clientID: "client-id", clientSecret: "client-secret", }, cl) } func TestBitbucketDC(t *testing.T) { gin.SetMode(gin.TestMode) s := fixtures.Server() defer s.Close() c := &client{ urlAPI: s.URL, } server.Config.Server.StatusContext = "ci/woodpecker" server.Config.Server.StatusContextFormat = "{{ .context }}/{{ .event }}/{{ .workflow }}" ctx := t.Context() repo, err := c.Repo(ctx, fakeUser, model.ForgeRemoteID("1234"), "PRJ", "repo-slug") assert.NoError(t, err) assert.Equal(t, &model.Repo{ Name: "repo-slug-2", Owner: "PRJ", Perm: &model.Perm{Pull: true, Push: true}, Branch: "main", IsSCMPrivate: true, PREnabled: true, ForgeRemoteID: model.ForgeRemoteID("1234"), FullName: "PRJ/repo-slug-2", }, repo) // org org, err := c.Org(ctx, fakeUser, "ORG") assert.NoError(t, err) assert.Equal(t, &model.Org{ Name: "ORG", IsUser: false, }, org) // user org, err = c.Org(ctx, fakeUser, "~ORG") assert.NoError(t, err) assert.Equal(t, &model.Org{ Name: "~ORG", IsUser: true, }, org) // Execute the Status method err = c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow) assert.NoError(t, err) } var ( fakeUser = &model.User{ AccessToken: "fake", Expiry: time.Now().Add(1 * time.Hour).Unix(), } fakeRepo = &model.Repo{ ID: 1, Owner: "test-owner", Name: "test-repo", Branch: "main", } fakePipeline = &model.Pipeline{ ID: 1, Number: 42, Commit: "3ce383490b3d90d79460c60f67ba2580acc6cc59", Started: 1759825800, Finished: 1759825883, Branch: "feature-branch", Ref: "refs/pull-requests/123/from", Event: model.EventPush, } fakeWorkflow = &model.Workflow{ ID: 1, PID: 1, Name: "build", State: model.StatusSuccess, } ) ================================================ FILE: server/forge/bitbucketdatacenter/convert.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucketdatacenter import ( "fmt" "net/url" "strings" "time" "github.com/neticdk/go-bitbucket/bitbucket" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func convertStatus(status model.StatusValue) bitbucket.BuildStatusState { switch status { case model.StatusPending, model.StatusRunning: return bitbucket.BuildStatusStateInProgress case model.StatusSuccess: return bitbucket.BuildStatusStateSuccessful default: return bitbucket.BuildStatusStateFailed } } func convertID(id uint64) model.ForgeRemoteID { return model.ForgeRemoteID(fmt.Sprintf("%d", id)) } func anonymizeLink(link string) (href string) { parsed, err := url.Parse(link) if err != nil { return link } parsed.User = nil return parsed.String() } func convertRepo(from *bitbucket.Repository, perm *model.Perm, branch string) *model.Repo { r := &model.Repo{ ForgeRemoteID: convertID(from.ID), Name: from.Slug, Owner: from.Project.Key, Branch: branch, IsSCMPrivate: true, // Since we have to use Netrc it has to always be private :/ TODO: Is this really true? FullName: fmt.Sprintf("%s/%s", from.Project.Key, from.Slug), Perm: perm, PREnabled: true, } for _, l := range from.Links["clone"] { if l.Name == "http" { r.Clone = anonymizeLink(l.Href) } } if l, ok := from.Links["self"]; ok && len(l) > 0 { r.ForgeURL = l[0].Href } return r } func convertRepositoryPushEvent(ev *bitbucket.RepositoryPushEvent, baseURL string) *model.Pipeline { if len(ev.Changes) == 0 { return nil } change := ev.Changes[0] if change.ToHash == "0000000000000000000000000000000000000000" { // No ToHash present - could be "DELETE" return nil } if change.Type == bitbucket.RepositoryPushEventChangeTypeDelete { return nil } pipeline := &model.Pipeline{ Commit: change.ToHash, Branch: change.Ref.DisplayID, Message: "", Avatar: bitbucketAvatarURL(baseURL, ev.Actor.Slug), Author: authorLabel(ev.Actor.Name), Email: ev.Actor.Email, Timestamp: time.Time(ev.Date).UTC().Unix(), Ref: ev.Changes[0].RefId, ForgeURL: fmt.Sprintf("%s/projects/%s/repos/%s/commits/%s", baseURL, ev.Repository.Project.Key, ev.Repository.Slug, change.ToHash), } if strings.HasPrefix(ev.Changes[0].RefId, "refs/tags/") { pipeline.Event = model.EventTag } else { pipeline.Event = model.EventPush } return pipeline } func convertGetCommitRange(ev *bitbucket.RepositoryPushEvent) (currCommit, prevCommit string) { if len(ev.Changes) == 0 { return "", "" } change := ev.Changes[0] if change.FromHash == "0000000000000000000000000000000000000000" { return change.ToHash, "" } else if change.ToHash == "0000000000000000000000000000000000000000" { return "", change.FromHash } return change.ToHash, change.FromHash } func convertPullRequestEvent(ev *bitbucket.PullRequestEvent, baseURL string) *model.Pipeline { pipeline := &model.Pipeline{ Commit: ev.PullRequest.Source.Latest, Branch: ev.PullRequest.Source.DisplayID, Title: ev.PullRequest.Title, Message: ev.PullRequest.Title, Avatar: bitbucketAvatarURL(baseURL, ev.Actor.Slug), Author: authorLabel(ev.Actor.Name), Email: ev.Actor.Email, Timestamp: time.Time(ev.Date).UTC().Unix(), Ref: fmt.Sprintf("refs/pull-requests/%d/from", ev.PullRequest.ID), ForgeURL: fmt.Sprintf("%s/projects/%s/repos/%s/commits/%s", baseURL, ev.PullRequest.Source.Repository.Project.Key, ev.PullRequest.Source.Repository.Slug, ev.PullRequest.Source.Latest), Refspec: fmt.Sprintf("%s:%s", ev.PullRequest.Source.DisplayID, ev.PullRequest.Target.DisplayID), FromFork: ev.PullRequest.Source.Repository.ID != ev.PullRequest.Target.Repository.ID, } if ev.EventKey == bitbucket.EventKeyPullRequestMerged || ev.EventKey == bitbucket.EventKeyPullRequestDeclined || ev.EventKey == bitbucket.EventKeyPullRequestDeleted { pipeline.Event = model.EventPullClosed } else { pipeline.Event = model.EventPull } return pipeline } func authorLabel(name string) string { var result string const maxNameLength = 40 if len(name) > maxNameLength { result = name[0:37] + "..." } else { result = name } return result } func convertUser(user *bitbucket.User, baseURL string) *model.User { return &model.User{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprintf("%d", user.ID)), Login: user.Slug, Email: user.Email, Avatar: bitbucketAvatarURL(baseURL, user.Slug), } } func bitbucketAvatarURL(baseURL, slug string) string { return fmt.Sprintf("%s/users/%s/avatar.png", baseURL, slug) } func convertListOptions(p *model.ListOptions) bitbucket.ListOptions { if p.All { return bitbucket.ListOptions{} } return bitbucket.ListOptions{Limit: uint(p.PerPage), Start: uint((p.Page - 1) * p.PerPage)} } func updateUserCredentials(u *model.User, t *oauth2.Token) { u.AccessToken = t.AccessToken u.RefreshToken = t.RefreshToken u.Expiry = t.Expiry.UTC().Unix() } func convertProjectsToTeams(projects []*bitbucket.Project, client *bitbucket.Client) []*model.Team { teams := make([]*model.Team, 0) for _, project := range projects { team := &model.Team{ Login: project.Key, Avatar: fmt.Sprintf("%s/projects/%s/avatar.png", client.BaseURL, project.Key), } teams = append(teams, team) } return teams } ================================================ FILE: server/forge/bitbucketdatacenter/convert_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucketdatacenter import ( "net/url" "testing" "time" "github.com/neticdk/go-bitbucket/bitbucket" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_convertStatus(t *testing.T) { tests := []struct { from model.StatusValue to bitbucket.BuildStatusState }{ { from: model.StatusPending, to: bitbucket.BuildStatusStateInProgress, }, { from: model.StatusRunning, to: bitbucket.BuildStatusStateInProgress, }, { from: model.StatusSuccess, to: bitbucket.BuildStatusStateSuccessful, }, { from: model.StatusValue("other"), to: bitbucket.BuildStatusStateFailed, }, } for _, tt := range tests { to := convertStatus(tt.from) assert.Equal(t, tt.to, to) } } func Test_convertRepo(t *testing.T) { from := &bitbucket.Repository{ ID: uint64(1234), Slug: "REPO", Project: &bitbucket.Project{ Key: "PRJ", }, Links: map[string][]bitbucket.Link{ "clone": { { Name: "http", Href: "https://user@git.domain/clone", }, }, "self": { { Href: "https://git.domain/self", }, }, }, } perm := &model.Perm{} to := convertRepo(from, perm, "main") assert.Equal(t, &model.Repo{ ForgeRemoteID: model.ForgeRemoteID("1234"), Name: "REPO", Owner: "PRJ", Branch: "main", FullName: "PRJ/REPO", Perm: perm, Clone: "https://git.domain/clone", ForgeURL: "https://git.domain/self", PREnabled: true, IsSCMPrivate: true, }, to) } func Test_convertRepositoryPushEvent(t *testing.T) { now := time.Now() tests := []struct { from *bitbucket.RepositoryPushEvent to *model.Pipeline }{ { from: &bitbucket.RepositoryPushEvent{}, to: nil, }, { from: &bitbucket.RepositoryPushEvent{ Changes: []bitbucket.RepositoryPushEventChange{ { FromHash: "1234567890abcdef", ToHash: "0000000000000000000000000000000000000000", }, }, }, to: nil, }, { from: &bitbucket.RepositoryPushEvent{ Changes: []bitbucket.RepositoryPushEventChange{ { FromHash: "0000000000000000000000000000000000000000", ToHash: "1234567890abcdef", Type: bitbucket.RepositoryPushEventChangeTypeDelete, }, }, }, to: nil, }, { from: &bitbucket.RepositoryPushEvent{ Event: bitbucket.Event{ Date: bitbucket.ISOTime(now), Actor: bitbucket.User{ Name: "John Doe", Email: "john.doe@mail.com", Slug: "john.doe_mail.com", }, }, Repository: bitbucket.Repository{ Slug: "REPO", Project: &bitbucket.Project{ Key: "PRJ", }, }, Changes: []bitbucket.RepositoryPushEventChange{ { Ref: bitbucket.RepositoryPushEventRef{ ID: "refs/head/branch", DisplayID: "branch", }, RefId: "refs/head/branch", ToHash: "1234567890abcdef", }, }, }, to: &model.Pipeline{ Commit: "1234567890abcdef", Branch: "branch", Message: "", Avatar: "https://base.url/users/john.doe_mail.com/avatar.png", Author: "John Doe", Email: "john.doe@mail.com", Timestamp: now.UTC().Unix(), Ref: "refs/head/branch", ForgeURL: "https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef", Event: model.EventPush, }, }, } for _, tt := range tests { to := convertRepositoryPushEvent(tt.from, "https://base.url") assert.Equal(t, tt.to, to) } } func Test_convertPullRequestEvent(t *testing.T) { now := time.Now() from := &bitbucket.PullRequestEvent{ Event: bitbucket.Event{ Date: bitbucket.ISOTime(now), EventKey: bitbucket.EventKeyPullRequestFrom, Actor: bitbucket.User{ Name: "John Doe", Email: "john.doe@mail.com", Slug: "john.doe_mail.com", }, }, PullRequest: bitbucket.PullRequest{ ID: 123, Title: "my title", Source: bitbucket.PullRequestRef{ ID: "refs/head/branch", DisplayID: "branch", Latest: "1234567890abcdef", Repository: bitbucket.Repository{ Slug: "REPO", Project: &bitbucket.Project{ Key: "PRJ", }, }, }, Target: bitbucket.PullRequestRef{ ID: "refs/head/main", DisplayID: "main", Latest: "abcdef1234567890", Repository: bitbucket.Repository{ Slug: "REPO", Project: &bitbucket.Project{ Key: "PRJ", }, }, }, }, } to := convertPullRequestEvent(from, "https://base.url") assert.Equal(t, &model.Pipeline{ Commit: "1234567890abcdef", Branch: "branch", Avatar: "https://base.url/users/john.doe_mail.com/avatar.png", Author: "John Doe", Email: "john.doe@mail.com", Timestamp: now.UTC().Unix(), Ref: "refs/pull-requests/123/from", ForgeURL: "https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef", Event: model.EventPull, Refspec: "branch:main", Title: "my title", Message: "my title", }, to) } func Test_convertPullRequestCloseEvent(t *testing.T) { now := time.Now() from := &bitbucket.PullRequestEvent{ Event: bitbucket.Event{ Date: bitbucket.ISOTime(now), EventKey: bitbucket.EventKeyPullRequestMerged, Actor: bitbucket.User{ Name: "John Doe", Email: "john.doe@mail.com", Slug: "john.doe_mail.com", }, }, PullRequest: bitbucket.PullRequest{ ID: 123, Title: "my title", Source: bitbucket.PullRequestRef{ ID: "refs/head/branch", DisplayID: "branch", Latest: "1234567890abcdef", Repository: bitbucket.Repository{ Slug: "REPO", Project: &bitbucket.Project{ Key: "PRJ", }, }, }, Target: bitbucket.PullRequestRef{ ID: "refs/head/main", DisplayID: "main", Latest: "abcdef1234567890", Repository: bitbucket.Repository{ Slug: "REPO", Project: &bitbucket.Project{ Key: "PRJ", }, }, }, }, } to := convertPullRequestEvent(from, "https://base.url") assert.Equal(t, &model.Pipeline{ Commit: "1234567890abcdef", Branch: "branch", Avatar: "https://base.url/users/john.doe_mail.com/avatar.png", Author: "John Doe", Email: "john.doe@mail.com", Timestamp: now.UTC().Unix(), Ref: "refs/pull-requests/123/from", ForgeURL: "https://base.url/projects/PRJ/repos/REPO/commits/1234567890abcdef", Event: model.EventPullClosed, Refspec: "branch:main", Title: "my title", Message: "my title", }, to) } func Test_authorLabel(t *testing.T) { tests := []struct { from string to string }{ { from: "Some Short Author", to: "Some Short Author", }, { from: "Some Very Long Author That May Include Multiple Names Here", //nolint:misspell to: "Some Very Long Author That May Includ...", }, } for _, tt := range tests { assert.Equal(t, tt.to, authorLabel(tt.from)) } } func Test_convertUser(t *testing.T) { from := &bitbucket.User{ Slug: "slug", Email: "john.doe@mail.com", ID: 1, } to := convertUser(from, "https://base.url") assert.Equal(t, &model.User{ Login: "slug", Avatar: "https://base.url/users/slug/avatar.png", Email: "john.doe@mail.com", ForgeRemoteID: "1", }, to) } func Test_convertProjectsToTeams(t *testing.T) { tests := []struct { projects []*bitbucket.Project baseURL string expected []*model.Team }{ { projects: []*bitbucket.Project{ { Key: "PRJ1", }, { Key: "PRJ2", }, }, baseURL: "https://base.url", expected: []*model.Team{ { Login: "PRJ1", Avatar: "https://base.url/projects/PRJ1/avatar.png", }, { Login: "PRJ2", Avatar: "https://base.url/projects/PRJ2/avatar.png", }, }, }, { projects: []*bitbucket.Project{}, baseURL: "https://base.url", expected: []*model.Team{}, }, } for _, tt := range tests { // Parse the baseURL string into a *url.URL parsedURL, err := url.Parse(tt.baseURL) assert.NoError(t, err) mockClient := &bitbucket.Client{BaseURL: parsedURL} actual := convertProjectsToTeams(tt.projects, mockClient) assert.Equal(t, tt.expected, actual) } } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/HookPullRequestMerged.json ================================================ { "date": "2025-09-11T14:53:09+0300", "actor": { "emailAddress": "john.doe@example.com", "displayName": "EXT Doe John", "name": "john.doe@example.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 13581, "type": "NORMAL", "slug": "john.doe_example.com" }, "eventKey": "pr:merged", "pullRequest": { "author": { "approved": false, "role": "AUTHOR", "user": { "emailAddress": "john.doe@example.com", "displayName": "EXT Doe John", "name": "john.doe@example.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 13581, "type": "NORMAL", "slug": "john.doe_example.com" }, "status": "UNAPPROVED" }, "description": "Updates ArgoCD to version where the CVE is patched.", "updatedDate": 1757591589232, "title": "chore(CVE-2025-55190): bump argocd", "version": 2, "reviewers": [ { "approved": false, "role": "REVIEWER", "user": { "emailAddress": "jane.smith@contractor.com", "displayName": "EXT Smith Jane", "name": "jane.smith@contractor.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/jane.smith_contractor.com" }] }, "id": 9374, "type": "NORMAL", "slug": "jane.smith_contractor.com" }, "status": "UNAPPROVED" }, { "approved": false, "role": "REVIEWER", "user": { "emailAddress": "mike.johnson@vendor.com", "displayName": "EXT Johnson Mike", "name": "mike.johnson@vendor.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/mike.johnson_vendor.com" }] }, "id": 15107, "type": "NORMAL", "slug": "mike.johnson_vendor.com" }, "status": "UNAPPROVED" }, { "approved": true, "role": "REVIEWER", "user": { "emailAddress": "alex.brown@freelance.com", "displayName": "EXT Brown Alex", "name": "alex.brown@freelance.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/alex.brown_freelance.com" }] }, "id": 13360, "type": "NORMAL", "slug": "alex.brown_freelance.com" }, "lastReviewedCommit": "993203acecdb65ffe947424d0917768b0e5c3903", "status": "APPROVED" } ], "toRef": { "latestCommit": "2bbf6d0c36db47566a934ab8f8e391e1ee54d392", "id": "refs/heads/master", "displayId": "master", "type": "BRANCH", "repository": { "archived": false, "public": false, "hierarchyId": "da7793ace13b18fa55a5", "name": "deployment-automation", "forkable": true, "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/deployment-automation.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse" }] }, "id": 1684, "scmId": "git", "state": "AVAILABLE", "slug": "deployment-automation", "statusMessage": "Available" } }, "createdDate": 1757571094582, "closedDate": 1757591589232, "draft": false, "closed": true, "fromRef": { "latestCommit": "993203acecdb65ffe947424d0917768b0e5c3903", "id": "refs/heads/PROJ-4584", "displayId": "PROJ-4584", "type": "BRANCH", "repository": { "archived": false, "public": false, "hierarchyId": "da7793ace13b18fa55a5", "name": "deployment-automation", "forkable": true, "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/deployment-automation.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse" }] }, "id": 1684, "scmId": "git", "state": "AVAILABLE", "slug": "deployment-automation", "statusMessage": "Available" } }, "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/pull-requests/111" }] }, "id": 111, "state": "MERGED", "locked": false, "open": false, "properties": { "mergeCommit": { "id": "c690da9e7f6a6d90defe03d57b8802df149c4aff", "displayId": "c690da9e7f6" } }, "participants": [] } } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/HookPullRequestOpened.json ================================================ { "date": "2025-09-19T09:07:23+0300", "actor": { "emailAddress": "mike.johnson@vendor.com", "displayName": "EXT Johnson Mike", "name": "mike.johnson@vendor.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/mike.johnson_vendor.com" }] }, "id": 15107, "type": "NORMAL", "slug": "mike.johnson_vendor.com" }, "eventKey": "pr:opened", "pullRequest": { "author": { "approved": false, "role": "AUTHOR", "user": { "emailAddress": "mike.johnson@vendor.com", "displayName": "EXT Johnson Mike", "name": "mike.johnson@vendor.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/mike.johnson_vendor.com" }] }, "id": 15107, "type": "NORMAL", "slug": "mike.johnson_vendor.com" }, "status": "UNAPPROVED" }, "description": "#### What?\n\nGather statistics about active repositories Woodpecker", "updatedDate": 1758262043663, "title": "feat: gather statistics about Woodpecker migration", "version": 0, "reviewers": [ { "approved": false, "role": "REVIEWER", "user": { "emailAddress": "jane.smith@contractor.com", "displayName": "EXT Smith Jane", "name": "jane.smith@contractor.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/jane.smith_contractor.com" }] }, "id": 9374, "type": "NORMAL", "slug": "jane.smith_contractor.com" }, "status": "UNAPPROVED" }, { "approved": false, "role": "REVIEWER", "user": { "emailAddress": "john.doe@example.com", "displayName": "EXT Doe John", "name": "john.doe@example.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 13581, "type": "NORMAL", "slug": "john.doe_example.com" }, "status": "UNAPPROVED" }, { "approved": false, "role": "REVIEWER", "user": { "emailAddress": "alex.brown@freelance.com", "displayName": "EXT Brown Alex", "name": "alex.brown@freelance.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/alex.brown_freelance.com" }] }, "id": 13360, "type": "NORMAL", "slug": "alex.brown_freelance.com" }, "status": "UNAPPROVED" } ], "toRef": { "latestCommit": "3767a5d2d2223447d03838654baa271fc15d94df", "id": "refs/heads/main", "displayId": "main", "type": "BRANCH", "repository": { "hierarchyId": "618693f8805af6d8f5c7", "description": "Network Monitor", "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "statusMessage": "Available", "archived": false, "public": false, "name": "network-monitor", "forkable": true, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/network-monitor.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/network-monitor.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/network-monitor/browse" }] }, "id": 1079, "scmId": "git", "state": "AVAILABLE", "slug": "network-monitor" } }, "createdDate": 1758262043663, "draft": false, "closed": false, "fromRef": { "latestCommit": "1c7589876bc8b5e83122b1656925d679915193d4", "id": "refs/heads/PROJ-4596", "displayId": "PROJ-4596", "type": "BRANCH", "repository": { "hierarchyId": "618693f8805af6d8f5c7", "description": "Network Monitor", "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "statusMessage": "Available", "archived": false, "public": false, "name": "network-monitor", "forkable": true, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/network-monitor.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/network-monitor.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/network-monitor/browse" }] }, "id": 1079, "scmId": "git", "state": "AVAILABLE", "slug": "network-monitor" } }, "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/network-monitor/pull-requests/125" }] }, "id": 125, "state": "OPEN", "locked": false, "open": true, "participants": [] } } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/HookPullRequestOpenedFromFork.json ================================================ { "date": "2025-09-22T13:36:11+0300", "actor": { "emailAddress": "john.doe@example.com", "displayName": "EXT Doe John", "name": "john.doe@example.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 13581, "type": "NORMAL", "slug": "john.doe_example.com" }, "eventKey": "pr:opened", "pullRequest": { "author": { "approved": false, "role": "AUTHOR", "user": { "emailAddress": "john.doe@example.com", "displayName": "EXT Doe John", "name": "john.doe@example.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 13581, "type": "NORMAL", "slug": "john.doe_example.com" }, "status": "UNAPPROVED" }, "updatedDate": 1758537371875, "title": "testing PROJ-4600", "version": 0, "reviewers": [], "toRef": { "latestCommit": "8c49fecb1363fffdf00456cedaaff6a50613725a", "id": "refs/heads/master", "displayId": "master", "type": "BRANCH", "repository": { "archived": false, "public": false, "hierarchyId": "da7793ace13b18fa55a5", "name": "deployment-automation", "forkable": true, "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/deployment-automation.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse" }] }, "id": 1684, "scmId": "git", "state": "AVAILABLE", "slug": "deployment-automation", "statusMessage": "Available" } }, "createdDate": 1758537371875, "draft": false, "closed": false, "fromRef": { "latestCommit": "716e510cecbe203618609cf103c54e040b949739", "id": "refs/heads/master", "displayId": "master", "type": "BRANCH", "repository": { "hierarchyId": "da7793ace13b18fa55a5", "origin": { "archived": false, "public": false, "hierarchyId": "da7793ace13b18fa55a5", "name": "deployment-automation", "forkable": true, "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/deployment-automation.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse" }] }, "id": 1684, "scmId": "git", "state": "AVAILABLE", "slug": "deployment-automation", "statusMessage": "Available" }, "project": { "owner": { "emailAddress": "john.doe@example.com", "displayName": "EXT Doe John", "name": "john.doe@example.com", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 13581, "type": "NORMAL", "slug": "john.doe_example.com" }, "name": "EXT Doe John", "links": { "self": [{ "href": "https://bitbucket.example.com/users/john.doe_example.com" }] }, "id": 1120, "type": "PERSONAL", "key": "~JOHN.DOE_EXAMPLE.COM" }, "statusMessage": "Available", "archived": false, "public": false, "name": "deployment-automation", "forkable": true, "links": { "clone": [ { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/~john.doe_example.com/deployment-automation.git" }, { "name": "http", "href": "https://bitbucket.example.com/scm/~john.doe_example.com/deployment-automation.git" } ], "self": [ { "href": "https://bitbucket.example.com/users/john.doe_example.com/repos/deployment-automation/browse" } ] }, "id": 1856, "scmId": "git", "state": "AVAILABLE", "slug": "deployment-automation" } }, "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/pull-requests/114" }] }, "id": 114, "state": "OPEN", "locked": false, "open": true, "participants": [] } } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/HookPush.json ================================================ { "date": "2025-09-23T03:15:55+0300", "actor": { "emailAddress": "renovatebot@example.com", "displayName": "Renovate Bot", "name": "renovatebot", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/renovatebot" }] }, "id": 14570, "type": "NORMAL", "slug": "renovatebot" }, "toCommit": { "committer": { "emailAddress": "renovatebot@example.com", "displayName": "Renovate Bot", "name": "renovatebot", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/renovatebot" }] }, "id": 14570, "type": "NORMAL", "slug": "renovatebot" }, "committerTimestamp": 1758586555000, "author": { "emailAddress": "renovatebot@example.com", "displayName": "Renovate Bot", "name": "renovatebot", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/renovatebot" }] }, "id": 14570, "type": "NORMAL", "slug": "renovatebot" }, "authorTimestamp": 1758586555000, "id": "76797d54bca87db6d1e3e82ee40622c7908aa514", "displayId": "76797d54bca", "message": "chore(deps): update all", "parents": [ { "committer": { "emailAddress": "john.doe@example.com", "name": "John Doe" }, "committerTimestamp": 1757592099000, "author": { "emailAddress": "john.doe@example.com", "name": "John Doe" }, "authorTimestamp": 1757592099000, "id": "8c49fecb1363fffdf00456cedaaff6a50613725a", "displayId": "8c49fecb136", "message": "chore: bump deployment automation version", "parents": [ { "id": "c690da9e7f6a6d90defe03d57b8802df149c4aff", "displayId": "c690da9e7f6" } ] } ] }, "eventKey": "repo:refs_changed", "changes": [ { "ref": { "id": "refs/heads/renovate-all", "displayId": "renovate-all", "type": "BRANCH" }, "fromHash": "e0e15221b987fd8296141c0faa6a79f7c86ca4ce", "toHash": "76797d54bca87db6d1e3e82ee40622c7908aa514", "refId": "refs/heads/renovate-all", "type": "UPDATE" } ], "commits": [ { "committer": { "emailAddress": "renovatebot@example.com", "displayName": "Renovate Bot", "name": "renovatebot", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/renovatebot" }] }, "id": 14570, "type": "NORMAL", "slug": "renovatebot" }, "committerTimestamp": 1758586555000, "author": { "emailAddress": "renovatebot@example.com", "displayName": "Renovate Bot", "name": "renovatebot", "active": true, "links": { "self": [{ "href": "https://bitbucket.example.com/users/renovatebot" }] }, "id": 14570, "type": "NORMAL", "slug": "renovatebot" }, "authorTimestamp": 1758586555000, "id": "76797d54bca87db6d1e3e82ee40622c7908aa514", "displayId": "76797d54bca", "message": "chore(deps): update all", "parents": [ { "id": "8c49fecb1363fffdf00456cedaaff6a50613725a", "displayId": "8c49fecb136" } ] } ], "repository": { "archived": false, "public": false, "hierarchyId": "da7793ace13b18fa55a5", "name": "deployment-automation", "forkable": true, "project": { "public": false, "name": "devops-team", "description": "DevOps Team", "links": { "self": [{ "href": "https://bitbucket.example.com/projects/DEV" }] }, "id": 565, "type": "NORMAL", "key": "DEV" }, "links": { "clone": [ { "name": "http", "href": "https://bitbucket.example.com/scm/dev/deployment-automation.git" }, { "name": "ssh", "href": "ssh://git@bitbucket.example.com:7999/dev/deployment-automation.git" } ], "self": [{ "href": "https://bitbucket.example.com/projects/DEV/repos/deployment-automation/browse" }] }, "id": 1684, "scmId": "git", "state": "AVAILABLE", "slug": "deployment-automation", "statusMessage": "Available" } } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/expected/PostBuildStatus.json ================================================ { "key": "ci/woodpecker/push/build", "state": "SUCCESSFUL", "url": "/repos/1/pipeline/42/1", "dateAdded": 1759825800000, "description": "Pipeline was successful", "duration": 83000, "parent": "ci/woodpecker/push/build", "ref": "refs/heads/feature-branch" } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/handler.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( "embed" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "github.com/neticdk/go-bitbucket/bitbucket" "github.com/neticdk/go-bitbucket/mock" "github.com/stretchr/testify/assert" ) var ( //go:embed expected/* embeddedFixtures embed.FS PostBuildStatus = mock.EndpointPattern{Pattern: "/api/latest/projects/:projectKey/repos/:repositorySlug/commits/:commitId/builds", Method: "POST"} ) type ResponseContent map[string]any func Server() *httptest.Server { return mock.NewMockServer( mock.WithRequestMatch(mock.SearchRepositories, bitbucket.RepositoryList{ ListResponse: bitbucket.ListResponse{ LastPage: true, }, Repositories: []*bitbucket.Repository{ { ID: uint64(123), Slug: "repo-slug-1", Name: "REPO Name 1", Project: &bitbucket.Project{ ID: uint64(456), Key: "PRJ", }, }, { ID: uint64(1234), Slug: "repo-slug-2", Name: "REPO Name 2", Project: &bitbucket.Project{ ID: uint64(456), Key: "PRJ", }, }, }, }), mock.WithRequestMatch(mock.GetRepository, bitbucket.Repository{ ID: uint64(123), Slug: "repo-slug", Name: "REPO Name", Project: &bitbucket.Project{ ID: uint64(456), Key: "PRJ", }, }), mock.WithRequestMatch(mock.GetDefaultBranch, bitbucket.Branch{ ID: "refs/head/main", DisplayID: "main", Default: true, }), mock.WithRequestMatchHandler(PostBuildStatus, ExpectedContentHandler( "PostBuildStatus.json", http.StatusNoContent, nil, http.StatusBadRequest, ResponseContent{ "errors": []ResponseContent{ { "context": "", "exceptionName": "", "message": "invalid branch was provided", }, }, }, )), ) } func ExpectedContentHandler(expectedFileName string, successCode int, successContent ResponseContent, failCode int, failContent ResponseContent) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { expectedContent, err := loadExpectedContent(expectedFileName) if err != nil { writeResponse(w, http.StatusInternalServerError, ResponseContent{"error": "Internal Server Error"}) return } var requestBody ResponseContent if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { writeResponse(w, failCode, failContent) return } if !assert.ObjectsAreEqual(requestBody, expectedContent) { writeResponse(w, failCode, failContent) return } writeResponse(w, successCode, successContent) } } func loadExpectedContent(fileName string) (ResponseContent, error) { file, err := embeddedFixtures.Open(filepath.Join("expected", fileName)) if err != nil { return nil, err } defer file.Close() var content ResponseContent err = json.NewDecoder(file).Decode(&content) return content, err } func writeResponse(w http.ResponseWriter, statusCode int, content ResponseContent) { w.WriteHeader(statusCode) if content != nil { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(content); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) } } } ================================================ FILE: server/forge/bitbucketdatacenter/fixtures/hooks.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import _ "embed" //go:embed HookPullRequestOpenedFromFork.json var HookPullFork string //go:embed HookPush.json var HookPush string //go:embed HookPullRequestMerged.json var HookPullMerged string //go:embed HookPullRequestOpened.json var HookPull string ================================================ FILE: server/forge/bitbucketdatacenter/internal/client.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package internal import ( "context" "fmt" "io" "net/http" "strings" "golang.org/x/oauth2" ) const ( currentUserID = "%s/plugins/servlet/applinks/whoami" // cspell:disable-line ) type Client struct { client *http.Client base string } func NewClientWithToken(ctx context.Context, ts oauth2.TokenSource, url string) *Client { return &Client{ client: oauth2.NewClient(ctx, ts), base: url, } } // FindCurrentUser is returning the current user id - however it is not really part of the API so it is not part of the Bitbucket go client. func (c *Client) FindCurrentUser(ctx context.Context) (string, error) { url := fmt.Sprintf(currentUserID, c.base) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("unable to create http request: %w", err) } resp, err := c.client.Do(req) if resp != nil { defer resp.Body.Close() } if err != nil { return "", fmt.Errorf("unable to query logged in user id: %w", err) } buf, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("unable to read data from user id query: %w", err) } login := string(buf) login = strings.ReplaceAll(login, "@", "_") // Apparently the "whoami" endpoint may return the "wrong" username - converting to user slug return login, nil } ================================================ FILE: server/forge/bitbucketdatacenter/internal/client_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package internal import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "golang.org/x/oauth2" ) func TestCurrentUser(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`tal@netic.dk`)) })) defer s.Close() ctx := t.Context() ts := mockSource("bearer-token") client := NewClientWithToken(ctx, ts, s.URL) uid, err := client.FindCurrentUser(ctx) assert.NoError(t, err) assert.Equal(t, "tal_netic.dk", uid) } type mockSource string func (ds mockSource) Token() (*oauth2.Token, error) { return &oauth2.Token{AccessToken: string(ds)}, nil } ================================================ FILE: server/forge/bitbucketdatacenter/parse.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucketdatacenter import ( "fmt" "net/http" "github.com/neticdk/go-bitbucket/bitbucket" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type HookResult struct { Repo *model.Repo Pipeline *model.Pipeline Event any Payload []byte } func parseHook(r *http.Request, baseURL string) (*HookResult, string, string, error) { ev, payload, err := bitbucket.ParsePayloadWithoutSignature(r) if err != nil { return nil, "", "", fmt.Errorf("unable to parse payload from webhook invocation: %w", err) } result := &HookResult{ Event: ev, Payload: payload, } switch e := ev.(type) { case *bitbucket.RepositoryPushEvent: result.Repo = convertRepo(&e.Repository, nil, "") result.Pipeline = convertRepositoryPushEvent(e, baseURL) currCommit, prevCommit := convertGetCommitRange(e) return result, currCommit, prevCommit, nil case *bitbucket.PullRequestEvent: result.Repo = convertRepo(&e.PullRequest.Target.Repository, nil, "") result.Pipeline = convertPullRequestEvent(e, baseURL) return result, "", "", nil default: return nil, "", "", &types.ErrIgnoreEvent{Event: fmt.Sprintf("%T", e), Reason: "unsupported webhook event type"} } } ================================================ FILE: server/forge/bitbucketdatacenter/parse_test.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package bitbucketdatacenter import ( "bytes" "net/http" "testing" "github.com/neticdk/go-bitbucket/bitbucket" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_parseHook(t *testing.T) { t.Run("pull-request opened", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPull) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Event-Key", "pr:opened") result, curCommit, prevCommit, err := parseHook(req, "https://bitbucket.example.com") assert.NoError(t, err) assert.NotNil(t, result) assert.Empty(t, curCommit) assert.Empty(t, prevCommit) assert.IsType(t, &bitbucket.PullRequestEvent{}, result.Event) assert.NotNil(t, result.Repo) assert.NotNil(t, result.Pipeline) assert.NotNil(t, result.Payload) assert.Equal(t, "DEV/network-monitor", result.Repo.FullName) assert.Equal(t, "1c7589876bc8b5e83122b1656925d679915193d4", result.Pipeline.Commit) assert.Equal(t, model.EventPull, result.Pipeline.Event) }) t.Run("pull-request opened from fork", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullFork) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Event-Key", "pr:opened") result, curCommit, prevCommit, err := parseHook(req, "https://bitbucket.example.com") assert.NoError(t, err) assert.NotNil(t, result) assert.Empty(t, curCommit) assert.Empty(t, prevCommit) assert.IsType(t, &bitbucket.PullRequestEvent{}, result.Event) assert.NotNil(t, result.Repo) assert.NotNil(t, result.Pipeline) assert.NotNil(t, result.Payload) assert.Equal(t, "DEV/deployment-automation", result.Repo.FullName) assert.Equal(t, "716e510cecbe203618609cf103c54e040b949739", result.Pipeline.Commit) assert.Equal(t, model.EventPull, result.Pipeline.Event) }) t.Run("push hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Event-Key", "repo:refs_changed") result, curCommit, prevCommit, err := parseHook(req, "https://bitbucket.example.com") assert.NoError(t, err) assert.NotNil(t, result) assert.IsType(t, &bitbucket.RepositoryPushEvent{}, result.Event) assert.NotNil(t, result.Repo) assert.NotNil(t, result.Pipeline) assert.NotNil(t, result.Payload) assert.Equal(t, curCommit, "76797d54bca87db6d1e3e82ee40622c7908aa514") assert.Equal(t, prevCommit, "e0e15221b987fd8296141c0faa6a79f7c86ca4ce") assert.Equal(t, "DEV/deployment-automation", result.Repo.FullName) assert.Equal(t, "76797d54bca87db6d1e3e82ee40622c7908aa514", result.Pipeline.Commit) assert.Equal(t, model.EventPush, result.Pipeline.Event) }) t.Run("pull-request merged", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullMerged) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Event-Key", "pr:merged") result, curCommit, prevCommit, err := parseHook(req, "https://bitbucket.example.com") assert.NoError(t, err) assert.NotNil(t, result) assert.Empty(t, curCommit) assert.Empty(t, prevCommit) assert.IsType(t, &bitbucket.PullRequestEvent{}, result.Event) assert.NotNil(t, result.Repo) assert.NotNil(t, result.Pipeline) assert.NotNil(t, result.Payload) assert.Equal(t, "DEV/deployment-automation", result.Repo.FullName) assert.Equal(t, "993203acecdb65ffe947424d0917768b0e5c3903", result.Pipeline.Commit) assert.Equal(t, model.EventPullClosed, result.Pipeline.Event) }) } ================================================ FILE: server/forge/common/event_normalize.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common func NormalizeEventReason(in string) string { switch in { case "labels_cleared": return "label_cleared" case "labels_updated": return "label_updated" case "labels_added": return "label_added" } return in } ================================================ FILE: server/forge/common/status.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "bytes" "fmt" "text/template" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func GetPipelineStatusContext(repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) string { event := string(pipeline.Event) if pipeline.Event == model.EventPull { event = "pr" } tmpl, err := template.New("context").Parse(server.Config.Server.StatusContextFormat) if err != nil { log.Error().Err(err).Msg("could not create status from template") return "" } var ctx bytes.Buffer err = tmpl.Execute(&ctx, map[string]any{ "context": server.Config.Server.StatusContext, "event": event, "workflow": workflow.Name, "owner": repo.Owner, "repo": repo.Name, "axis_id": workflow.AxisID, }) if err != nil { log.Error().Err(err).Msg("could not create status context") return "" } return ctx.String() } // GetPipelineStatusDescription is a helper function that generates a description // message for the current pipeline status. func GetPipelineStatusDescription(status model.StatusValue) string { switch status { case model.StatusPending: return "Pipeline is pending" case model.StatusRunning: return "Pipeline is running" case model.StatusSuccess: return "Pipeline was successful" case model.StatusFailure, model.StatusError: return "Pipeline failed" case model.StatusKilled: return "Pipeline was canceled" case model.StatusBlocked: return "Pipeline is pending approval" case model.StatusDeclined: return "Pipeline was rejected" default: return "unknown status" } } func GetPipelineStatusURL(repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) string { if workflow == nil { return fmt.Sprintf("%s/repos/%d/pipeline/%d", server.Config.Server.Host, repo.ID, pipeline.Number) } return fmt.Sprintf("%s/repos/%d/pipeline/%d/%d", server.Config.Server.Host, repo.ID, pipeline.Number, workflow.PID) } ================================================ FILE: server/forge/common/status_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestGetPipelineStatusContext(t *testing.T) { origFormat := server.Config.Server.StatusContextFormat origCtx := server.Config.Server.StatusContext defer func() { server.Config.Server.StatusContextFormat = origFormat server.Config.Server.StatusContext = origCtx }() repo := &model.Repo{Owner: "user1", Name: "repo1"} pipeline := &model.Pipeline{Event: model.EventPull} workflow := &model.Workflow{Name: "lint"} assert.EqualValues(t, "", GetPipelineStatusContext(repo, pipeline, workflow)) server.Config.Server.StatusContext = "ci/woodpecker" server.Config.Server.StatusContextFormat = "{{ .context }}/{{ .event }}/{{ .workflow }}" assert.EqualValues(t, "ci/woodpecker/pr/lint", GetPipelineStatusContext(repo, pipeline, workflow)) pipeline.Event = model.EventPush assert.EqualValues(t, "ci/woodpecker/push/lint", GetPipelineStatusContext(repo, pipeline, workflow)) server.Config.Server.StatusContext = "ci" server.Config.Server.StatusContextFormat = "{{ .context }}:{{ .owner }}/{{ .repo }}:{{ .event }}:{{ .workflow }}" assert.EqualValues(t, "ci:user1/repo1:push:lint", GetPipelineStatusContext(repo, pipeline, workflow)) } ================================================ FILE: server/forge/common/utils.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "context" "errors" "net" "net/url" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func ExtractHostFromCloneURL(cloneURL string) (string, error) { u, err := url.Parse(cloneURL) if err != nil { return "", err } if !strings.Contains(u.Host, ":") { return u.Host, nil } host, _, err := net.SplitHostPort(u.Host) if err != nil { return "", err } return host, nil } func UserToken(ctx context.Context, r *model.Repo, u *model.User) string { if u != nil { return u.AccessToken } user, err := RepoUser(ctx, r) if err != nil { log.Error().Err(err).Msg("could not get repo user") return "" } return user.AccessToken } func RepoUser(ctx context.Context, r *model.Repo) (*model.User, error) { _store, ok := store.TryFromContext(ctx) if !ok { return nil, errors.New("could not get store from context") } if r == nil { log.Error().Msg("cannot get user token by empty repo") return nil, errors.New("cannot get user token by empty repo") } user, err := _store.GetUser(r.UserID) if err != nil { return nil, err } return user, nil } func RepoUserForgeID(ctx context.Context, forgeID int64, remoteID model.ForgeRemoteID) (*model.User, error) { _store, ok := store.TryFromContext(ctx) if !ok { return nil, errors.New("could not get store from context") } r, err := _store.GetRepoForgeID(forgeID, remoteID) if err != nil { return nil, err } return RepoUser(ctx, r) } ================================================ FILE: server/forge/common/utils_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common_test import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" ) func Test_Netrc(t *testing.T) { host, err := common.ExtractHostFromCloneURL("https://git.example.com/foo/bar.git") assert.NoError(t, err) assert.Equal(t, "git.example.com", host) } ================================================ FILE: server/forge/forge.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package forge defines the Forge interface for integrating with Git hosting // platforms (GitHub, GitLab, Gitea, Forgejo, Bitbucket, etc.). // // The Forge interface provides a unified abstraction for OAuth authentication, // repository management, webhook processing, and status reporting. package forge import ( "context" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // Forge defines the interface for integrating with Git hosting platforms. // // Architecture: // A Forge instance represents a single forge provider. Woodpecker supports // multiple forge instances simultaneously through ForgeManager. // Each User and Repo has a ForgeID field associating them with a specific forge. // // Thread Safety: // Implementations must be safe for concurrent use. Methods receive context.Context // for cancellation/timeout. Do not maintain user-specific state; user context is // passed via *model.User parameter. // // Authentication: // OAuth2-based authentication is assumed. Tokens are refreshed 30 minutes before // expiry via the optional Refresher interface. // // Configuration Fetching: // Pipeline configurations retrieved via File() or Dir() from Repo.Config path // with fallback to defaults. // // Error Handling: // - types.ErrIgnoreEvent: Skippable webhook events // - types.ErrRecordNotExist: Resource not found // - types.ErrNotImplemented: Can be used to signal it's not supported // - nil Repo/Pipeline: "No action needed" (not an error). type Forge interface { // Name returns the unique identifier of this forge driver. // Examples: "github", "gitlab", "gitea", "forgejo", "bitbucket" // Must be unique and constant across all implementations. Name() string // URL returns the root URL of the forge instance. // Examples: "https://github.com", "https://gitlab.example.com" URL() string // Login authenticates a user via OAuth2. // // OAuth Flow: // 1. Initial call with empty OAuthRequest.Code returns (nil, redirectURL, nil) // 2. User authorizes at redirectURL // 3. Second call with OAuthRequest.Code returns (User, redirectURL, nil) // // Returned User must contain: Login, Email, Avatar, AccessToken, RefreshToken, Expiry, ForgeRemoteID Login(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error) // Teams fetches all team/organization memberships for a user. // Used to determine if an user is member of an team/organization. // Should support pagination via ListOptions. // // Errors: // - Expect types.ErrNotImplemented to be returned if forge doesn't support teams/organizations. Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) // Repo fetches a single repository. // // Lookup Strategy: // - Prefer lookup by remoteID (forge's internal ID) if provided (more reliable as repos can be renamed) // - Fallback to owner/name if remoteID empty // // Must verify user has at least read access. // Caller must make sure ForgeID is set. Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) // Repos fetches all repositories accessible to the user. // Should include user's permission level in Repo.Perm. // Should support pagination via ListOptions. // Caller must make sure ForgeID is set. Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) // File fetches a single file at a specific commit. // Primary method for retrieving pipeline configuration files. // Must fetch at specific commit (b.Commit), not branch head. File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error) // Dir fetches all files in a directory at a specific commit. // Supports pipeline configurations split across multiple files. // Should return files only. // // Errors: // - Expect types.ErrNotImplemented to be returned if not supported by the forge Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error) // Status sends workflow status updates to the forge. // Provides visual feedback in forge UI (commit checks, PR status). // Failures should be logged but not block pipeline execution. Status(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error // Netrc generates .netrc credentials for cloning private repositories. // May receive nil user for public repos. Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) // Activate creates a webhook pointing to Woodpecker. // Called when user activates a repository. // Must verify user has admin access. Should set webhook secret from r.Hash. // Configure webhook for all events Hook() can parse. Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error // Deactivate removes the webhook. // Should ignore if webhook doesn't exist anymore. Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error // Branches returns all branch names in the repository. // Should support pagination via ListOptions. // // Errors: // - Expect types.ErrNotImplemented to be returned if not supported by the forge Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) // BranchHead returns the latest commit SHA for a branch. // Is essential for cron feature to work. BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) // PullRequests returns all open pull requests. // Should support pagination via ListOptions. // // Errors: // - Expect types.ErrNotImplemented to be returned if not supported by the forge PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) // Hook parses incoming webhook and returns pipeline data. // // Webhook Processing Flow: // 1. HTTP request arrives at /api/hook with forge-specific format // 2. Webhook token verified against repo.Hash // 3. Hook() parses webhook and returns (Repo, Pipeline, error) // // Return Semantics: // - (repo, pipeline, nil): Execute pipeline for this event // - (repo, nil, nil): Valid webhook, no pipeline should run // - (nil, nil, types.ErrIgnoreEvent): Event ignored (logged) // - (nil, nil, error): Invalid webhook or parsing error // // Must verify webhook signature to prevent spoofing. // Should return types.ErrIgnoreEvent for non-pipeline events // (e.g. repository settings changed). Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) // OrgMembership checks if user is member of organization and their permission. // Should return (Member: false, Admin: false) if not a member. // // Errors: // - Expect types.ErrNotImplemented to be returned if not supported by the forge OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) // Org fetches organization details. // If identifier is a user, return org with IsUser: true. Org(ctx context.Context, u *model.User, org string) (*model.Org, error) } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequest.json ================================================ { "action": "opened", "number": 1, "pull_request": { "html_url": "http://forgejo.golang.org/gordon/hello-world/pull/1", "state": "open", "title": "Update the README with new information", "body": "please merge", "user": { "id": 1, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" }, "base": { "label": "main", "ref": "main", "sha": "9353195a19e45482665306e466c832c46560532d" }, "head": { "label": "feature/changes", "ref": "feature/changes", "sha": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c" } }, "repository": { "id": 35129377, "name": "hello-world", "full_name": "gordon/hello-world", "owner": { "id": 1, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" }, "private": true, "html_url": "http://forgejo.golang.org/gordon/hello-world", "clone_url": "https://forgejo.golang.org/gordon/hello-world.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true } }, "sender": { "id": 1, "login": "gordon", "username": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestAssigneeCleared.json ================================================ { "action": "unassigned", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [ { "id": 494011, "name": "Kind/Documentation", "color": "37474f", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494011" }, { "id": 494002, "name": "Kind/Enhancement", "color": "84b6eb", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002" } ], "milestone": { "id": 22669, "title": "mile v2" }, "assignee": null, "assignees": null, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestAssigneesAdded.json ================================================ { "action": "assigned", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [], "milestone": null, "assignee": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "assignees": [ { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } ], "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestClosed.json ================================================ { "action": "closed", "number": 1, "pull_request": { "id": 62112, "url": "https://forgejo.com/anbraten/test-repo/pulls/1", "number": 1, "user": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "title": "Adjust file", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": null, "state": "closed", "is_locked": false, "comments": 0, "html_url": "https://forgejo.com/anbraten/test-repo/pulls/1", "diff_url": "https://forgejo.com/anbraten/test-repo/pulls/1.diff", "patch_url": "https://forgejo.com/anbraten/test-repo/pulls/1.patch", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "head": { "label": "anbraten-patch-1", "ref": "anbraten-patch-1", "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "due_date": null, "created_at": "2023-12-05T18:06:38Z", "updated_at": "2023-12-05T18:06:43Z", "closed_at": "2023-12-05T18:06:43Z", "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@repo.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null }, "sender": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@sender.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestEdited.json ================================================ { "action": "edited", "number": 1, "pull_request": { "id": 62112, "url": "https://forgejo.com/anbraten/test-repo/pulls/1", "number": 1, "user": { "id": 26907, "login": "anbraten", "full_name": "", "email": "anbraten@forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "visibility": "public" }, "title": "Adjust file", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "html_url": "https://forgejo.com/anbraten/test-repo/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "visibility": "public" }, "name": "test-repo", "full_name": "anbraten/test-repo", "private": false, "fork": false, "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "anbraten-patch-1", "ref": "anbraten-patch-1", "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "visibility": "public" }, "name": "test-repo", "full_name": "anbraten/test-repo", "private": false, "fork": false, "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa" }, "repository": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "full_name": "", "email": "anbraten@repo.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "visibility": "public" }, "name": "test-repo", "full_name": "anbraten/test-repo", "private": false, "fork": false, "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 26907, "login": "anbraten", "full_name": "", "email": "anbraten@sender.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestLabelAdded.json ================================================ { "action": "label_updated", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [ { "id": 494011, "name": "Kind/Documentation", "color": "37474f", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494011" }, { "id": 494002, "name": "Kind/Enhancement", "color": "84b6eb", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002" } ], "milestone": { "id": 22669, "title": "mile v2" }, "assignee": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "assignees": [ { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } ], "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestLabelsCleared.json ================================================ { "action": "label_cleared", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [ { "id": 494002, "name": "Kind/Enhancement", "color": "84b6eb", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002" }, { "id": 494008, "name": "Kind/Testing", "color": "795548", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494008" } ], "milestone": { "id": 22666, "title": "mile v1" }, "assignee": null, "assignees": null, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestLabelsUpdated.json ================================================ { "action": "label_updated", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [ { "id": 494002, "name": "Kind/Enhancement", "color": "84b6eb", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002" }, { "id": 494008, "name": "Kind/Testing", "color": "795548", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494008" } ], "milestone": { "id": 22666, "title": "mile v1" }, "assignee": null, "assignees": null, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestMerged.json ================================================ { "action": "closed", "number": 1, "pull_request": { "id": 62112, "url": "https://forgejo.com/anbraten/test-repo/pulls/1", "number": 1, "user": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "title": "Adjust file", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": null, "state": "closed", "is_locked": false, "comments": 1, "html_url": "https://forgejo.com/anbraten/test-repo/pulls/1", "diff_url": "https://forgejo.com/anbraten/test-repo/pulls/1.diff", "patch_url": "https://forgejo.com/anbraten/test-repo/pulls/1.patch", "mergeable": true, "merged": true, "merged_at": "2023-12-05T18:35:31Z", "merge_commit_sha": "f2440f050054df0f8ecabcace648f1683509064c", "merged_by": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "f2440f050054df0f8ecabcace648f1683509064c", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "head": { "label": "anbraten-patch-1", "ref": "anbraten-patch-1", "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "due_date": null, "created_at": "2023-12-05T18:06:38Z", "updated_at": "2023-12-05T18:35:31Z", "closed_at": "2023-12-05T18:35:31Z", "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://forgejo.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://forgejo.com/anbraten/test-repo", "url": "https://forgejo.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@forgejo.com:anbraten/test-repo.git", "clone_url": "https://forgejo.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null }, "sender": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.forgejo.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestMilestoneAdded.json ================================================ { "action": "milestoned", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [], "milestone": { "id": 22669, "title": "mile v2" }, "assignee": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "assignees": [ { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } ], "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestMilestoneChanged.json ================================================ { "action": "milestoned", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [ { "id": 494011, "name": "Kind/Documentation", "color": "37474f", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494011" }, { "id": 494002, "name": "Kind/Enhancement", "color": "84b6eb", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/labels/494002" } ], "milestone": { "id": 22669, "title": "mile v2" }, "assignee": null, "assignees": null, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestMilestoneCleared.json ================================================ { "action": "demilestoned", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" }, "title": "Some ned more AAAA", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1" }, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "visibility": "public" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "private": false, "fork": false, "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "visibility": "public" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestReopened.json ================================================ { "action": "reopened", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "login_name": "", "source_id": 0, "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "language": "en-US", "is_admin": false, "last_login": "2025-08-05T17:04:55+02:00", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": true, "prohibit_login": false, "location": "", "pronouns": "", "website": "https://mh.obermui.de", "description": "\u003ca href=\"https://matrix.to/#/@marddl:obermui.de\" rel=\"nofollow\"\u003e\u003cimg src=\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\"\u003e\u003c/a\u003e\r\n\u003ca rel=\"me\" href=\"https://chaos.social/@6543\"\u003eMastodon\u003c/a\u003e", "visibility": "public", "followers_count": 46, "following_count": 33, "starred_repos_count": 92, "username": "6543" }, "title": "Some ned more AAAA", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": [], "requested_reviewers_teams": [], "state": "open", "draft": false, "is_locked": false, "comments": 0, "review_comments": 1, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "diff_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1.diff", "patch_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1.patch", "mergeable": false, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "login_name": "", "source_id": 0, "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-04-02T15:13:07+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "", "description": "the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "test_it" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 34, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages", "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "link": "", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "original_url": "", "website": "", "stars_count": 1, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-08-27T03:32:56+02:00", "updated_at": "2025-07-29T16:45:07+02:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "wiki_branch": "master", "globally_editable_wiki": false, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": true, "has_actions": false, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_fast_forward_only_merge": false, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "default_update_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "sha1", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null, "topics": [] } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "login_name": "", "source_id": 0, "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-04-02T15:13:07+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "", "description": "the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "test_it" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 34, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages", "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "link": "", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "original_url": "", "website": "", "stars_count": 1, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-08-27T03:32:56+02:00", "updated_at": "2025-07-29T16:45:07+02:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "wiki_branch": "master", "globally_editable_wiki": false, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": true, "has_actions": false, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_fast_forward_only_merge": false, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "default_update_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "sha1", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null, "topics": [] } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1", "due_date": null, "created_at": "2025-07-29T16:45:09+02:00", "updated_at": "2025-08-05T17:06:49+02:00", "closed_at": null, "pin_order": 0, "flow": 0 }, "requested_reviewer": null, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "login_name": "", "source_id": 0, "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-04-02T15:13:07+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "", "description": "the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "test_it" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 34, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages", "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "link": "", "ssh_url": "ssh://git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "original_url": "", "website": "", "stars_count": 1, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-08-27T03:32:56+02:00", "updated_at": "2025-07-29T16:45:07+02:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "wiki_branch": "master", "globally_editable_wiki": false, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": true, "has_actions": false, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_fast_forward_only_merge": false, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "default_update_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "sha1", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null, "topics": [] }, "sender": { "id": 2628, "login": "6543", "login_name": "", "source_id": 0, "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "https://mh.obermui.de", "description": "\u003ca href=\"https://matrix.to/#/@marddl:obermui.de\" rel=\"nofollow\"\u003e\u003cimg src=\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\"\u003e\u003c/a\u003e\r\n\u003ca rel=\"me\" href=\"https://chaos.social/@6543\"\u003eMastodon\u003c/a\u003e", "visibility": "public", "followers_count": 46, "following_count": 33, "starred_repos_count": 92, "username": "6543" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/forgejo/fixtures/HookPullRequestUpdated.json ================================================ { "action": "synchronized", "number": 2, "pull_request": { "id": 2, "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", "number": 2, "user": { "id": 1, "login": "test", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "visibility": "public", "username": "test" }, "title": "New Pull", "body": "create an awesome pull", "labels": [ { "id": 8, "name": "Kind/Bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/8" }, { "id": 11, "name": "Kind/Security", "exclusive": false, "is_archived": false, "color": "9c27b0", "description": "This is security issue", "url": "http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/11" } ], "milestone": null, "assignees": null, "requested_reviewers": null, "state": "open", "is_locked": false, "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", "diff_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.diff", "patch_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.patch", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "29be01c073851cf0db0c6a466e396b725a670453", "repo_id": 6 }, "head": { "label": "test-patch-1", "ref": "test-patch-1", "sha": "788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25", "repo_id": 6 }, "merge_base": "29be01c073851cf0db0c6a466e396b725a670453", "due_date": null, "created_at": "2024-02-22T01:38:39+01:00", "updated_at": "2024-02-22T01:42:03+01:00", "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 6, "owner": { "id": 2, "login": "Test-CI", "login_name": "", "full_name": "", "email": "", "avatar_url": "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:48+02:00", "prohibit_login": false, "visibility": "public", "username": "Test-CI" }, "name": "multi-line-secrets", "full_name": "Test-CI/multi-line-secrets", "description": "", "private": false, "languages_url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages", "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets", "url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets", "link": "", "ssh_url": "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", "clone_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", "original_url": "", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_pull_requests": true, "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "" }, "sender": { "id": 1, "login": "test", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "visibility": "public", "username": "test" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/forgejo/fixtures/HookPush.json ================================================ { "ref": "refs/heads/main", "before": "4b2626259b5a97b6b4eab5e6cca66adb986b672b", "after": "ef98532add3b2feb7a137426bba1248724367df5", "compare_url": "http://forgejo.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5", "commits": [ { "id": "ef98532add3b2feb7a137426bba1248724367df5", "message": "bump\n", "url": "http://forgejo.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5", "author": { "name": "Gordon the Gopher", "email": "gordon@golang.org", "username": "gordon" }, "added": ["CHANGELOG.md"], "removed": [], "modified": ["app/controller/application.rb"] } ], "repository": { "id": 1, "name": "hello-world", "full_name": "gordon/hello-world", "html_url": "http://forgejo.golang.org/gordon/hello-world", "ssh_url": "git@forgejo.golang.org:gordon/hello-world.git", "clone_url": "http://forgejo.golang.org/gordon/hello-world.git", "description": "", "website": "", "watchers": 1, "owner": { "name": "gordon", "email": "gordon@golang.org", "login": "gordon", "username": "gordon" }, "private": true, "permissions": { "admin": true, "push": true, "pull": true } }, "pusher": { "name": "gordon", "email": "gordon@golang.org", "username": "gordon", "login": "gordon" }, "sender": { "login": "gordon", "id": 1, "username": "gordon", "email": "gordon@golang.org", "avatar_url": "http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPushBranch.json ================================================ { "ref": "refs/heads/fdsafdsa", "before": "0000000000000000000000000000000000000000", "after": "28c3613ae62640216bea5e7dc71aa65356e4298b", "compare_url": "https://codeberg.org/meisam/woodpecktester/compare/main...28c3613ae62640216bea5e7dc71aa65356e4298b", "commits": [], "head_commit": { "id": "28c3613ae62640216bea5e7dc71aa65356e4298b", "message": "Delete '.woodpecker/.check.yml'\n", "url": "https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b", "author": { "name": "meisam", "email": "meisam@noreply.codeberg.org", "username": "meisam" }, "committer": { "name": "meisam", "email": "meisam@noreply.codeberg.org", "username": "meisam" }, "verification": null, "timestamp": "2022-07-12T21:09:27+02:00", "added": [], "removed": [".woodpecker/.check.yml"], "modified": [] }, "repository": { "id": 50820, "owner": { "id": 14844, "login": "meisam", "full_name": "", "email": "meisam@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2020-10-08T11:19:12+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "Materials engineer, physics enthusiast, large collection of the bad programming habits, always happy to fix the old ones and make new mistakes!", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "meisam", "permissions": { "admin": true, "push": true, "pull": true } }, "name": "woodpecktester", "full_name": "meisam/woodpecktester", "description": "Just for testing the Woodpecker CI and reporting bugs", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 367, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/meisam/woodpecktester/languages", "html_url": "https://codeberg.org/meisam/woodpecktester", "ssh_url": "git@codeberg.org:meisam/woodpecktester.git", "clone_url": "https://codeberg.org/meisam/woodpecktester.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2022-07-04T00:34:39+02:00", "updated_at": "2022-07-24T20:31:29+02:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "default_merge_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null }, "pusher": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "visibility": "public", "followers_count": 22, "following_count": 16, "starred_repos_count": 55, "username": "6543" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": false, "prohibit_login": false, "visibility": "public", "followers_count": 22, "following_count": 16, "starred_repos_count": 55, "username": "6543" } } ================================================ FILE: server/forge/forgejo/fixtures/HookPushMulti.json ================================================ { "ref": "refs/heads/main", "before": "6efcf5b7c98f3e7a491675164b7a2e7acac27941", "after": "29be01c073851cf0db0c6a466e396b725a670453", "compare_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453", "commits": [ { "id": "29be01c073851cf0db0c6a466e396b725a670453", "message": "add some text\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:18:07+01:00", "added": [], "removed": [], "modified": ["aaa"] }, { "id": "29cd95250404bd007c13b03eabe521196bab98a5", "message": "rm a a file\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29cd95250404bd007c13b03eabe521196bab98a5", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:17:49+01:00", "added": [], "removed": ["aa"], "modified": [] }, { "id": "93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91", "message": "add some a files\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:17:33+01:00", "added": ["aa", "aaa"], "removed": [], "modified": [] } ], "total_commits": 3, "head_commit": { "id": "29be01c073851cf0db0c6a466e396b725a670453", "message": "add some text\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:18:07+01:00", "added": [], "removed": [], "modified": ["aaa"] }, "repository": { "id": 6, "owner": { "id": 2, "login": "Test-CI", "login_name": "", "full_name": "", "email": "", "avatar_url": "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:48+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "Test-CI" }, "name": "multi-line-secrets", "full_name": "Test-CI/multi-line-secrets", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 35, "language": "", "languages_url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages", "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets", "url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets", "link": "", "ssh_url": "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", "clone_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", "original_url": "", "website": "", "watchers_count": 2, "open_issues_count": 1, "default_branch": "main", "archived": false, "created_at": "2023-10-31T19:53:15+01:00", "updated_at": "2023-11-02T06:16:34+01:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "avatar_url": "", "object_format_name": "" }, "pusher": { "id": 1, "login": "test-user", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "prohibit_login": false, "description": "", "visibility": "public", "username": "test-user" }, "sender": { "id": 1, "login": "test-user", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "prohibit_login": false, "description": "", "visibility": "public", "username": "test-user" } } ================================================ FILE: server/forge/forgejo/fixtures/HookRelease.json ================================================ { "action": "published", "release": { "id": 48, "tag_name": "0.0.5", "target_commitish": "main", "name": "Version 0.0.5", "body": "", "url": "https://git.xxx/api/v1/repos/anbraten/demo/releases/48", "html_url": "https://git.xxx/anbraten/demo/releases/tag/0.0.5", "tarball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.tar.gz", "zipball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.zip", "draft": false, "prerelease": false, "created_at": "2022-02-09T20:23:05Z", "published_at": "2022-02-09T20:23:05Z", "author": { "id": 1, "login": "anbraten", "full_name": "Anton Bracke", "email": "anbraten@noreply.xxx", "avatar_url": "https://git.xxx/user/avatar/anbraten/-1", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2018-03-21T10:04:48Z", "restricted": false, "active": false, "prohibit_login": false, "location": "world", "website": "https://xxx", "description": "", "visibility": "public", "followers_count": 1, "following_count": 1, "starred_repos_count": 1, "username": "anbraten" }, "assets": [] }, "repository": { "id": 77, "owner": { "id": 1, "login": "anbraten", "full_name": "Anton Bracke", "email": "anbraten@noreply.xxx", "avatar_url": "https://git.xxx/user/avatar/anbraten/-1", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2018-03-21T10:04:48Z", "restricted": false, "active": false, "prohibit_login": false, "location": "world", "website": "https://xxx", "description": "", "visibility": "public", "followers_count": 1, "following_count": 1, "starred_repos_count": 1, "username": "anbraten" }, "name": "demo", "full_name": "anbraten/demo", "description": "", "empty": false, "private": true, "fork": false, "template": false, "parent": null, "mirror": false, "size": 59, "html_url": "https://git.xxx/anbraten/demo", "ssh_url": "ssh://git@git.xxx:22/anbraten/demo.git", "clone_url": "https://git.xxx/anbraten/demo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 1, "watchers_count": 1, "open_issues_count": 2, "open_pr_counter": 2, "release_counter": 4, "default_branch": "main", "archived": false, "created_at": "2021-08-30T20:54:13Z", "updated_at": "2022-01-09T01:29:23Z", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": false, "has_pull_requests": true, "has_projects": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "default_merge_style": "squash", "avatar_url": "", "internal": false, "mirror_interval": "" }, "sender": { "id": 1, "login": "anbraten", "full_name": "Anbraten", "email": "anbraten@noreply.xxx", "avatar_url": "https://git.xxx/user/avatar/anbraten/-1", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2018-03-21T10:04:48Z", "restricted": false, "active": false, "prohibit_login": false, "location": "World", "website": "https://xxx", "description": "", "visibility": "public", "followers_count": 1, "following_count": 1, "starred_repos_count": 1, "username": "anbraten" } } ================================================ FILE: server/forge/forgejo/fixtures/HookTag.json ================================================ { "sha": "ef98532add3b2feb7a137426bba1248724367df5", "secret": "l26Un7G7HXogLAvsyf2hOA4EMARSTsR3", "ref": "v1.0.0", "ref_type": "tag", "repository": { "id": 12, "owner": { "id": 4, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" }, "name": "hello-world", "full_name": "gordon/hello-world", "description": "a hello world example", "private": true, "fork": false, "html_url": "http://forgejo.golang.org/gordon/hello-world", "ssh_url": "git@forgejo.golang.org:gordon/hello-world.git", "clone_url": "http://forgejo.golang.org/gordon/hello-world.git", "default_branch": "main", "created_at": "2015-10-22T19:32:44Z", "updated_at": "2016-11-24T13:37:16Z", "permissions": { "admin": true, "push": true, "pull": true } }, "sender": { "id": 1, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" } } ================================================ FILE: server/forge/forgejo/fixtures/handler.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( "net/http" "github.com/gin-gonic/gin" ) // Handler returns an http.Handler that is capable of handling a variety of mock // Forgejo requests and returning mock responses. func Handler() http.Handler { gin.SetMode(gin.TestMode) e := gin.New() e.GET("/api/v1/repos/:owner/:name", getRepo) e.GET("/api/v1/repositories/:id", getRepoByID) e.GET("/api/v1/repos/:owner/:name/raw/:file", getRepoFile) e.POST("/api/v1/repos/:owner/:name/hooks", createRepoHook) e.GET("/api/v1/repos/:owner/:name/hooks", listRepoHooks) e.DELETE("/api/v1/repos/:owner/:name/hooks/:id", deleteRepoHook) e.POST("/api/v1/repos/:owner/:name/statuses/:commit", createRepoCommitStatus) e.GET("/api/v1/repos/:owner/:name/pulls/:index/files", getPRFiles) e.GET("/api/v1/user/repos", getUserRepos) e.GET("/api/v1/version", getVersion) return e } func listRepoHooks(c *gin.Context) { page := c.Query("page") if page != "" && page != "1" { c.String(http.StatusOK, "[]") } else { c.String(http.StatusOK, listRepoHookPayloads) } } func getRepo(c *gin.Context) { switch c.Param("name") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoPayload) } } func getRepoByID(c *gin.Context) { switch c.Param("id") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoPayload) } } func createRepoCommitStatus(c *gin.Context) { if c.Param("commit") == "v1.0.0" || c.Param("commit") == "9ecad50" { c.String(http.StatusOK, repoPayload) } c.String(http.StatusNotFound, "") } func getRepoFile(c *gin.Context) { file := c.Param("file") ref := c.Query("ref") if file == "file_not_found" { c.String(http.StatusNotFound, "") } if ref == "v1.0.0" || ref == "9ecad50" { c.String(http.StatusOK, repoFilePayload) } c.String(http.StatusNotFound, "") } func createRepoHook(c *gin.Context) { in := struct { Type string `json:"type"` Conf struct { Type string `json:"content_type"` URL string `json:"url"` } `json:"config"` }{} _ = c.BindJSON(&in) if in.Type != "forgejo" || in.Conf.Type != "json" || in.Conf.URL != "http://localhost" { c.String(http.StatusInternalServerError, "") return } c.String(http.StatusOK, "{}") } func deleteRepoHook(c *gin.Context) { c.String(http.StatusOK, "{}") } func getUserRepos(c *gin.Context) { switch c.Request.Header.Get("Authorization") { case "token repos_not_found": c.String(http.StatusNotFound, "") default: page := c.Query("page") if page != "" && page != "1" { c.String(http.StatusOK, "[]") } else { c.String(http.StatusOK, userRepoPayload) } } } func getVersion(c *gin.Context) { c.JSON(http.StatusOK, map[string]any{"version": "1.18.0"}) } func getPRFiles(c *gin.Context) { page := c.Query("page") if page == "1" { c.String(http.StatusOK, prFilesPayload) } else { c.String(http.StatusOK, "[]") } } const listRepoHookPayloads = ` [ { "id": 1, "type": "forgejo", "config": { "content_type": "json", "url": "http:\/\/localhost\/hook?access_token=1234567890" } } ] ` const repoPayload = ` { "id": 5, "owner": { "login": "test_name", "email": "octocat@github.com", "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/8c58a0be77ee441bb8f8595b7f1b4e87" }, "full_name": "test_name\/repo_name", "private": true, "html_url": "http:\/\/localhost\/test_name\/repo_name", "clone_url": "http:\/\/localhost\/test_name\/repo_name.git", "permissions": { "admin": true, "push": true, "pull": true } } ` const repoFilePayload = `{ platform: linux/amd64 }` const userRepoPayload = ` [ { "id": 5, "owner": { "login": "test_name", "email": "octocat@github.com", "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/8c58a0be77ee441bb8f8595b7f1b4e87" }, "full_name": "test_name\/repo_name", "private": true, "html_url": "http:\/\/localhost\/test_name\/repo_name", "clone_url": "http:\/\/localhost\/test_name\/repo_name.git", "permissions": { "admin": true, "push": true, "pull": true } } ] ` const prFilesPayload = ` [ { "filename": "README.md", "status": "changed", "additions": 2, "deletions": 0, "changes": 2, "html_url": "http://localhost/username/repo/src/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md", "contents_url": "http://localhost:3000/api/v1/repos/username/repo/contents/README.md?ref=e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd", "raw_url": "http://localhost/username/repo/raw/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md" } ] ` ================================================ FILE: server/forge/forgejo/fixtures/hooks.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import _ "embed" // HookPush is a sample Forgejo push hook. // //go:embed HookPush.json var HookPush string // HookPushMulti push multible commits to a branch. // //go:embed HookPushMulti.json var HookPushMulti string // HookPushBranch is a sample Forgejo push hook where a new branch was created from an existing commit. // //go:embed HookPushBranch.json var HookPushBranch string // HookTag is a sample Forgejo tag hook. // //go:embed HookTag.json var HookTag string // HookPullRequest is a sample pull_request webhook payload. // //go:embed HookPullRequest.json var HookPullRequest string //go:embed HookPullRequestUpdated.json var HookPullRequestUpdated string //go:embed HookPullRequestMerged.json var HookPullRequestMerged string //go:embed HookPullRequestClosed.json var HookPullRequestClosed string //go:embed HookPullRequestEdited.json var HookPullRequestEdited string //go:embed HookRelease.json var HookRelease string //go:embed HookPullRequestAssigneesAdded.json var HookPullRequestAssigneesAdded string //go:embed HookPullRequestMilestoneAdded.json var HookPullRequestMilestoneAdded string //go:embed HookPullRequestLabelAdded.json var HookPullRequestLabelAdded string //go:embed HookPullRequestAssigneeCleared.json var HookPullRequestAssigneeCleared string //go:embed HookPullRequestMilestoneChanged.json var HookPullRequestMilestoneChanged string //go:embed HookPullRequestLabelsUpdated.json var HookPullRequestLabelsUpdated string //go:embed HookPullRequestLabelsCleared.json var HookPullRequestLabelsCleared string //go:embed HookPullRequestMilestoneCleared.json var HookPullRequestMilestoneCleared string //go:embed HookPullRequestReopened.json var HookPullRequestReopened string ================================================ FILE: server/forge/forgejo/forgejo.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import ( "context" "crypto/tls" "errors" "fmt" "net/http" "strconv" "strings" "time" "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" "github.com/rs/zerolog/log" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( authorizeTokenURL = "%s/login/oauth/authorize" accessTokenURL = "%s/login/oauth/access_token" defaultPageSize = 50 forgejoDevVersion = "v7.0.2" ) type Forgejo struct { id int64 url string oauth2URL string oAuthClientID string oAuthClientSecret string skipVerify bool pageSize int } // Opts defines configuration options. type Opts struct { URL string // Forgejo server url. OAuth2URL string // User-facing Forgejo server url for OAuth2. OAuthClientID string // OAuth2 Client ID OAuthClientSecret string // OAuth2 Client Secret SkipVerify bool // Skip ssl verification. } // New returns a Forge implementation that integrates with Forgejo, // an open source Git service written in Go. See https://forgejo.org/ func New(id int64, opts Opts) (forge.Forge, error) { if opts.OAuth2URL == "" { opts.OAuth2URL = opts.URL } return &Forgejo{ id: id, url: opts.URL, oauth2URL: opts.OAuth2URL, oAuthClientID: opts.OAuthClientID, oAuthClientSecret: opts.OAuthClientSecret, skipVerify: opts.SkipVerify, }, nil } // Name returns the string name of this driver. func (c *Forgejo) Name() string { return "forgejo" } // URL returns the root url of a configured forge. func (c *Forgejo) URL() string { return c.url } func (c *Forgejo) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) { return &oauth2.Config{ ClientID: c.oAuthClientID, ClientSecret: c.oAuthClientSecret, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf(authorizeTokenURL, c.oauth2URL), TokenURL: fmt.Sprintf(accessTokenURL, c.oauth2URL), }, RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), }, context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: c.skipVerify}, Proxy: http.ProxyFromEnvironment, }}) } // Login authenticates an account with Forgejo using basic authentication. The // Forgejo account details are returned when the user is successfully authenticated. func (c *Forgejo) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { config, oauth2Ctx := c.oauth2Config(ctx) redirectURL := config.AuthCodeURL(req.State) // check the OAuth code if len(req.Code) == 0 { return nil, redirectURL, nil } token, err := config.Exchange(oauth2Ctx, req.Code) if err != nil { return nil, redirectURL, err } client, err := c.newClientToken(ctx, token.AccessToken) if err != nil { return nil, redirectURL, err } account, _, err := client.GetMyUserInfo() if err != nil { return nil, redirectURL, err } return &model.User{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry.UTC().Unix(), Login: account.UserName, Email: account.Email, ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(account.ID)), Avatar: expandAvatar(c.url, account.AvatarURL), }, redirectURL, nil } // Refresh refreshes the Forgejo oauth2 access token. If the token is // refreshed, the user is updated and a true value is returned. func (c *Forgejo) Refresh(ctx context.Context, user *model.User) (bool, error) { config, oauth2Ctx := c.oauth2Config(ctx) config.RedirectURL = "" source := config.TokenSource(oauth2Ctx, &oauth2.Token{ AccessToken: user.AccessToken, RefreshToken: user.RefreshToken, Expiry: time.Unix(user.Expiry, 0), }) token, err := source.Token() if err != nil || len(token.AccessToken) == 0 { return false, err } user.AccessToken = token.AccessToken user.RefreshToken = token.RefreshToken user.Expiry = token.Expiry.UTC().Unix() return true, nil } // Teams is supported by the Forgejo driver. func (c *Forgejo) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { // we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667) if p.Page != 1 { return nil, nil } client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } return shared_utils.Paginate(func(page int) ([]*model.Team, error) { orgs, _, err := client.ListMyOrgs( forgejo.ListOrgsOptions{ ListOptions: forgejo.ListOptions{ Page: page, PageSize: c.perPage(ctx), }, }, ) teams := make([]*model.Team, 0, len(orgs)) for _, org := range orgs { teams = append(teams, toTeam(org, c.url)) } return teams, err }, -1) } // TeamPerm is not supported by the Forgejo driver. func (c *Forgejo) TeamPerm(_ *model.User, _ string) (*model.Perm, error) { return nil, nil } // Repo returns the Forgejo repository. func (c *Forgejo) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } if remoteID.IsValid() { intID, err := strconv.ParseInt(string(remoteID), 10, 64) if err != nil { return nil, err } repo, resp, err := client.GetRepoByID(intID) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return nil, err } return toRepo(repo), nil } repo, resp, err := client.GetRepo(owner, name) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return nil, err } return toRepo(repo), nil } // Repos returns a list of all repositories for the Forgejo account, including // organization repositories. func (c *Forgejo) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { // we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667) if p.Page != 1 { return nil, nil } client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } repos, err := shared_utils.Paginate(func(page int) ([]*forgejo.Repository, error) { repos, _, err := client.ListMyRepos( forgejo.ListReposOptions{ ListOptions: forgejo.ListOptions{ Page: page, PageSize: c.perPage(ctx), }, }, ) return repos, err }, -1) result := make([]*model.Repo, 0, len(repos)) for _, repo := range repos { if repo.Archived { continue } result = append(result, toRepo(repo)) } return result, err } // File fetches the file from the Forgejo repository and returns its contents. func (c *Forgejo) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } cfg, resp, err := client.GetFile(r.Owner, r.Name, b.Commit, f) if err != nil && resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}}) } return cfg, err } func (c *Forgejo) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) { var configs []*forge_types.FileMeta client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } // List files in repository contents, resp, err := client.ListContents(r.Owner, r.Name, b.Commit, f) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}}) } return nil, err } for _, e := range contents { if e.Type == "file" { data, err := c.File(ctx, u, r, b, e.Path) if err != nil { return nil, fmt.Errorf("multi-pipeline cannot get %s: %w", e.Path, err) } configs = append(configs, &forge_types.FileMeta{ Name: e.Path, Data: data, }) } } return configs, nil } // Status is supported by the Forgejo driver. func (c *Forgejo) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { client, err := c.newClientToken(ctx, user.AccessToken) if err != nil { return err } _, _, err = client.CreateStatus( repo.Owner, repo.Name, pipeline.Commit, forgejo.CreateStatusOption{ State: getStatus(workflow.State), TargetURL: common.GetPipelineStatusURL(repo, pipeline, workflow), Description: common.GetPipelineStatusDescription(workflow.State), Context: common.GetPipelineStatusContext(repo, pipeline, workflow), }, ) return err } // Netrc returns a netrc file capable of authenticating Forgejo requests and // cloning Forgejo repositories. The netrc will use the global machine account // when configured. func (c *Forgejo) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { login := "" token := "" if u != nil { login = u.Login token = u.AccessToken } host, err := common.ExtractHostFromCloneURL(r.Clone) if err != nil { return nil, err } return &model.Netrc{ Login: login, Password: token, Machine: host, Type: model.ForgeTypeForgejo, }, nil } // Activate activates the repository by registering post-commit hooks with // the Forgejo repository. func (c *Forgejo) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { config := map[string]string{ "url": link, "secret": r.Hash, "content_type": "json", } hook := forgejo.CreateHookOption{ Type: forgejo.HookTypeForgejo, Config: config, Events: []string{"push", "create", "pull_request", "release"}, Active: true, } client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return err } _, response, err := client.CreateRepoHook(r.Owner, r.Name, hook) if err != nil { if response != nil { if response.StatusCode == http.StatusNotFound { return fmt.Errorf("could not find repository") } if response.StatusCode == http.StatusOK { return fmt.Errorf("could not find repository, repository was probably renamed") } } return err } return nil } // Deactivate deactivates the repository be removing repository push hooks from // the Forgejo repository. func (c *Forgejo) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return err } // make sure a repo rename does not trick us forgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name) if err != nil { return err } hooks, err := shared_utils.Paginate(func(page int) ([]*forgejo.Hook, error) { hooks, _, err := client.ListRepoHooks(forgeRepo.Owner, forgeRepo.Name, forgejo.ListHooksOptions{ ListOptions: forgejo.ListOptions{ Page: page, PageSize: c.perPage(ctx), }, }) return hooks, err }, -1) if err != nil { return err } hook := matchingHooks(hooks, link) if hook != nil { _, err := client.DeleteRepoHook(forgeRepo.Owner, forgeRepo.Name, hook.ID) return err } return nil } // Branches returns the names of all branches for the named repository. func (c *Forgejo) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { token := common.UserToken(ctx, r, u) client, err := c.newClientToken(ctx, token) if err != nil { return nil, err } branches, _, err := client.ListRepoBranches(r.Owner, r.Name, forgejo.ListRepoBranchesOptions{ListOptions: forgejo.ListOptions{Page: p.Page, PageSize: p.PerPage}}) if err != nil { return nil, err } result := make([]string, len(branches)) for i := range branches { result[i] = branches[i].Name } return result, err } // BranchHead returns the sha of the head (latest commit) of the specified branch. func (c *Forgejo) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { token := common.UserToken(ctx, r, u) client, err := c.newClientToken(ctx, token) if err != nil { return nil, err } b, _, err := client.GetRepoBranch(r.Owner, r.Name, branch) if err != nil { return nil, err } return &model.Commit{ SHA: b.Commit.ID, ForgeURL: b.Commit.URL, }, nil } func (c *Forgejo) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { token := common.UserToken(ctx, r, u) client, err := c.newClientToken(ctx, token) if err != nil { return nil, err } pullRequests, _, err := client.ListRepoPullRequests(r.Owner, r.Name, forgejo.ListPullRequestsOptions{ ListOptions: forgejo.ListOptions{Page: p.Page, PageSize: p.PerPage}, State: forgejo.StateOpen, }) if err != nil { return nil, err } result := make([]*model.PullRequest, len(pullRequests)) for i := range pullRequests { result[i] = &model.PullRequest{ Index: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].Index))), Title: pullRequests[i].Title, } } return result, err } // Hook parses the incoming Forgejo hook and returns the Repository and Pipeline // details. If the hook is unsupported nil values are returned. func (c *Forgejo) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { repo, pipeline, err := parseHook(r) if err != nil { return nil, nil, err } if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" { tagName := strings.Split(pipeline.Ref, "/")[2] sha, err := c.getTagCommitSHA(ctx, repo, tagName) if err != nil { return nil, nil, err } pipeline.Commit = sha } if pipeline != nil && pipeline.IsPullRequest() && len(pipeline.ChangedFiles) == 0 { index, err := strconv.ParseInt(strings.Split(pipeline.Ref, "/")[2], 10, 64) if err != nil { return nil, nil, err } pipeline.ChangedFiles, err = c.getChangedFilesForPR(ctx, repo, index) if err != nil { log.Error().Err(err).Msgf("could not get changed files for PR %s#%d", repo.FullName, index) } } return repo, pipeline, nil } // OrgMembership returns if user is member of organization and if user // is admin/owner in this organization. func (c *Forgejo) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } member, _, err := client.CheckOrgMembership(owner, u.Login) if err != nil { return nil, err } if !member { return &model.OrgPerm{}, nil } perm, _, err := client.GetOrgPermissions(owner, u.Login) if err != nil { return &model.OrgPerm{Member: member}, err } return &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil } func (c *Forgejo) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } org, _, orgErr := client.GetOrg(owner) if orgErr == nil && org != nil { return &model.Org{ Name: org.UserName, Private: forgejo.VisibleType(org.Visibility) != forgejo.VisibleTypePublic, }, nil } user, _, err := client.GetUserInfo(owner) if err != nil { if orgErr != nil { err = errors.Join(orgErr, err) } return nil, err } return &model.Org{ Name: user.UserName, IsUser: true, Private: user.Visibility != forgejo.VisibleTypePublic, }, nil } // newClientToken returns a Forgejo client with token. func (c *Forgejo) newClientToken(ctx context.Context, token string) (*forgejo.Client, error) { httpClient := &http.Client{} if c.skipVerify { httpClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } wrappedClient := httputil.WrapClient(httpClient, "forge-forgejo") client, err := forgejo.NewClient(c.url, forgejo.SetToken(token), forgejo.SetHTTPClient(wrappedClient), forgejo.SetContext(ctx)) if err != nil && (errors.Is(err, &forgejo.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) { // we guess it's a dev forgejo version log.Error().Err(err).Msgf("could not detect forgejo version, assume dev version %s", forgejoDevVersion) client, err = forgejo.NewClient(c.url, forgejo.SetForgejoVersion(forgejoDevVersion), forgejo.SetToken(token), forgejo.SetHTTPClient(wrappedClient), forgejo.SetContext(ctx)) } return client, err } // getStatus is a helper function that converts a Woodpecker // status to a Forgejo status. func getStatus(status model.StatusValue) forgejo.StatusState { switch status { case model.StatusPending, model.StatusBlocked: return forgejo.StatusPending case model.StatusRunning: return forgejo.StatusPending case model.StatusSuccess: return forgejo.StatusSuccess case model.StatusFailure: return forgejo.StatusFailure case model.StatusKilled: return forgejo.StatusFailure case model.StatusDeclined: return forgejo.StatusWarning case model.StatusError: return forgejo.StatusError default: return forgejo.StatusFailure } } func (c *Forgejo) getChangedFilesForPR(ctx context.Context, repo *model.Repo, index int64) ([]string, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return []string{}, nil } repo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName) if err != nil { return nil, err } user, err := _store.GetUser(repo.UserID) if err != nil { return nil, err } forge.Refresh(ctx, c, _store, user) client, err := c.newClientToken(ctx, user.AccessToken) if err != nil { return nil, err } return shared_utils.Paginate(func(page int) ([]string, error) { forgejoFiles, _, err := client.ListPullRequestFiles(repo.Owner, repo.Name, index, forgejo.ListPullRequestFilesOptions{ListOptions: forgejo.ListOptions{Page: page}}) if err != nil { return nil, err } var files []string for _, file := range forgejoFiles { files = append(files, file.Filename) } return files, nil }, -1) } func (c *Forgejo) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return "", nil } repo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName) if err != nil { return "", err } user, err := _store.GetUser(repo.UserID) if err != nil { return "", err } forge.Refresh(ctx, c, _store, user) client, err := c.newClientToken(ctx, user.AccessToken) if err != nil { return "", err } tag, _, err := client.GetTag(repo.Owner, repo.Name, tagName) if err != nil { return "", err } return tag.Commit.SHA, nil } func (c *Forgejo) perPage(ctx context.Context) int { if c.pageSize == 0 { client, err := c.newClientToken(ctx, "") if err != nil { return defaultPageSize } api, _, err := client.GetGlobalAPISettings() if err != nil { return defaultPageSize } c.pageSize = api.MaxResponseItems } return c.pageSize } ================================================ FILE: server/forge/forgejo/forgejo_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import ( "bytes" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestNew(t *testing.T) { forge, _ := New(1, Opts{ URL: "http://localhost:8080", SkipVerify: true, }) f, _ := forge.(*Forgejo) assert.Equal(t, "http://localhost:8080", f.url) assert.True(t, f.skipVerify) } func Test_forgejo(t *testing.T) { gin.SetMode(gin.TestMode) s := httptest.NewServer(fixtures.Handler()) defer s.Close() c, _ := New(1, Opts{ URL: s.URL, SkipVerify: true, }) mockStore := store_mocks.NewMockStore(t) ctx := store.InjectToContext(t.Context(), mockStore) t.Run("netrc with user token", func(t *testing.T) { forge, _ := New(1, Opts{}) netrc, _ := forge.Netrc(fakeUser, fakeRepo) assert.Equal(t, "forgejo.org", netrc.Machine) assert.Equal(t, fakeUser.Login, netrc.Login) assert.Equal(t, fakeUser.AccessToken, netrc.Password) assert.Equal(t, model.ForgeTypeForgejo, netrc.Type) }) t.Run("netrc with machine account", func(t *testing.T) { forge, _ := New(1, Opts{}) netrc, _ := forge.Netrc(nil, fakeRepo) assert.Equal(t, "forgejo.org", netrc.Machine) assert.Empty(t, netrc.Login) assert.Empty(t, netrc.Password) }) t.Run("repository details", func(t *testing.T) { repo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name) assert.NoError(t, err) assert.Equal(t, fakeRepo.Owner, repo.Owner) assert.Equal(t, fakeRepo.Name, repo.Name) assert.Equal(t, fakeRepo.Owner+"/"+fakeRepo.Name, repo.FullName) assert.True(t, repo.IsSCMPrivate) assert.Equal(t, "http://localhost/test_name/repo_name.git", repo.Clone) assert.Equal(t, "http://localhost/test_name/repo_name", repo.ForgeURL) }) t.Run("repo not found", func(t *testing.T) { _, err := c.Repo(ctx, fakeUser, "0", fakeRepoNotFound.Owner, fakeRepoNotFound.Name) assert.Error(t, err) }) t.Run("repository list", func(t *testing.T) { repos, err := c.Repos(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10}) assert.NoError(t, err) assert.Equal(t, fakeRepo.ForgeRemoteID, repos[0].ForgeRemoteID) assert.Equal(t, fakeRepo.Owner, repos[0].Owner) assert.Equal(t, fakeRepo.Name, repos[0].Name) assert.Equal(t, fakeRepo.Owner+"/"+fakeRepo.Name, repos[0].FullName) }) t.Run("not found error", func(t *testing.T) { _, err := c.Repos(ctx, fakeUserNoRepos, &model.ListOptions{Page: 1, PerPage: 10}) assert.Error(t, err) }) t.Run("register repository", func(t *testing.T) { err := c.Activate(ctx, fakeUser, fakeRepo, "http://localhost") assert.NoError(t, err) }) t.Run("remove hooks", func(t *testing.T) { err := c.Deactivate(ctx, fakeUser, fakeRepo, "http://localhost") assert.NoError(t, err) }) t.Run("repository file", func(t *testing.T) { raw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, ".woodpecker.yml") assert.NoError(t, err) assert.Equal(t, "{ platform: linux/amd64 }", string(raw)) }) t.Run("pipeline status", func(t *testing.T) { err := c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow) assert.NoError(t, err) }) t.Run("PR hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPullRequest) mockStore.On("GetRepoNameFallback", mock.Anything, mock.Anything, mock.Anything).Return(fakeRepo, nil) mockStore.On("GetUser", mock.Anything).Return(fakeUser, nil) r, b, err := c.Hook(ctx, req) assert.NotNil(t, r) assert.NotNil(t, b) assert.NoError(t, err) assert.Equal(t, model.EventPull, b.Event) assert.Equal(t, []string{"README.md"}, b.ChangedFiles) }) } var ( fakeUser = &model.User{ Login: "someuser", AccessToken: "cfcd2084", } fakeUserNoRepos = &model.User{ Login: "someuser", AccessToken: "repos_not_found", } fakeRepo = &model.Repo{ Clone: "http://forgejo.org/test_name/repo_name.git", ForgeRemoteID: "5", Owner: "test_name", Name: "repo_name", FullName: "test_name/repo_name", } fakeRepoNotFound = &model.Repo{ Owner: "test_name", Name: "repo_not_found", FullName: "test_name/repo_not_found", } fakePipeline = &model.Pipeline{ Commit: "9ecad50", } fakeWorkflow = &model.Workflow{ Name: "test", State: model.StatusSuccess, } ) ================================================ FILE: server/forge/forgejo/helper.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import ( "encoding/json" "fmt" "io" "net/url" "strings" "time" "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) // toRepo converts a Forgejo repository to a Woodpecker repository. func toRepo(from *forgejo.Repository) *model.Repo { name := strings.Split(from.FullName, "/")[1] avatar := expandAvatar( from.HTMLURL, from.Owner.AvatarURL, ) return &model.Repo{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.ID)), Name: name, Owner: from.Owner.UserName, FullName: from.FullName, Avatar: avatar, ForgeURL: from.HTMLURL, IsSCMPrivate: from.Private || from.Owner.Visibility != forgejo.VisibleTypePublic, Clone: from.CloneURL, CloneSSH: from.SSHURL, Branch: from.DefaultBranch, Perm: toPerm(from.Permissions), PREnabled: from.HasPullRequests, } } // toPerm converts a Forgejo permission to a Woodpecker permission. func toPerm(from *forgejo.Permission) *model.Perm { return &model.Perm{ Pull: from.Pull, Push: from.Push, Admin: from.Admin, } } // toTeam converts a Forgejo team to a Woodpecker team. func toTeam(from *forgejo.Organization, link string) *model.Team { return &model.Team{ Login: from.UserName, Avatar: expandAvatar(link, from.AvatarURL), } } // pipelineFromPush extracts the Pipeline data from a Forgejo push hook. func pipelineFromPush(hook *pushHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.Sender.AvatarURL), ) var message string link := hook.Compare if len(hook.Commits) > 0 { message = hook.Commits[0].Message if len(hook.Commits) == 1 { link = hook.Commits[0].URL } } else { message = hook.HeadCommit.Message link = hook.HeadCommit.URL } return &model.Pipeline{ Event: model.EventPush, Commit: hook.After, Ref: hook.Ref, ForgeURL: link, Branch: strings.TrimPrefix(hook.Ref, "refs/heads/"), Message: message, Avatar: avatar, Author: hook.Sender.UserName, Email: hook.Sender.Email, Timestamp: time.Now().UTC().Unix(), Sender: hook.Sender.UserName, ChangedFiles: getChangedFilesFromPushHook(hook), } } func getChangedFilesFromPushHook(hook *pushHook) []string { // assume a capacity of 4 changed files per commit files := make([]string, 0, len(hook.Commits)*4) for _, c := range hook.Commits { files = append(files, c.Added...) files = append(files, c.Removed...) files = append(files, c.Modified...) } files = append(files, hook.HeadCommit.Added...) files = append(files, hook.HeadCommit.Removed...) files = append(files, hook.HeadCommit.Modified...) return utils.DeduplicateStrings(files) } // pipelineFromTag extracts the Pipeline data from a Forgejo tag hook. func pipelineFromTag(hook *pushHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.Sender.AvatarURL), ) ref := strings.TrimPrefix(hook.Ref, "refs/tags/") return &model.Pipeline{ Event: model.EventTag, Commit: hook.Sha, Ref: fmt.Sprintf("refs/tags/%s", ref), ForgeURL: fmt.Sprintf("%s/src/tag/%s", hook.Repo.HTMLURL, ref), Message: fmt.Sprintf("created tag %s", ref), Avatar: avatar, Author: hook.Sender.UserName, Sender: hook.Sender.UserName, Email: hook.Sender.Email, Timestamp: time.Now().UTC().Unix(), } } // pipelineFromPullRequest extracts the Pipeline data from a Forgejo pull_request hook. func pipelineFromPullRequest(hook *pullRequestHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.PullRequest.Poster.AvatarURL), ) event := model.EventPull switch hook.Action { case actionClose: event = model.EventPullClosed case actionEdited, actionLabelUpdate, actionLabelCleared, actionMilestoned, actionDeMilestoned, actionAssigned, actionUnAssigned: event = model.EventPullMetadata } pipeline := &model.Pipeline{ Event: event, Commit: hook.PullRequest.Head.Sha, ForgeURL: hook.PullRequest.HTMLURL, Ref: fmt.Sprintf("refs/pull/%d/head", hook.Number), Branch: hook.PullRequest.Base.Ref, Message: hook.PullRequest.Title, Author: hook.PullRequest.Poster.UserName, Avatar: avatar, Sender: hook.Sender.UserName, Email: hook.Sender.Email, Title: hook.PullRequest.Title, Refspec: fmt.Sprintf("%s:%s", hook.PullRequest.Head.Ref, hook.PullRequest.Base.Ref, ), PullRequestLabels: convertLabels(hook.PullRequest.Labels), PullRequestMilestone: convertMilestone(hook.PullRequest.Milestone), FromFork: hook.PullRequest.Head.RepoID != hook.PullRequest.Base.RepoID, } if pipeline.Event == model.EventPullMetadata { pipeline.EventReason = []string{hook.Action} } return pipeline } func convertMilestone(milestone *forgejo.Milestone) string { if milestone == nil || milestone.ID == 0 { return "" } return milestone.Title } func pipelineFromRelease(hook *releaseHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.Sender.AvatarURL), ) return &model.Pipeline{ Event: model.EventRelease, Ref: fmt.Sprintf("refs/tags/%s", hook.Release.TagName), ForgeURL: hook.Release.HTMLURL, Branch: hook.Release.Target, Message: fmt.Sprintf("created release %s", hook.Release.Title), Avatar: avatar, Author: hook.Sender.UserName, Sender: hook.Sender.UserName, Email: hook.Sender.Email, IsPrerelease: hook.Release.IsPrerelease, } } // helper function that parses a push hook from a read closer. func parsePush(r io.Reader) (*pushHook, error) { push := new(pushHook) err := json.NewDecoder(r).Decode(push) return push, err } func parsePullRequest(r io.Reader) (*pullRequestHook, error) { pr := new(pullRequestHook) err := json.NewDecoder(r).Decode(pr) return pr, err } func parseRelease(r io.Reader) (*releaseHook, error) { pr := new(releaseHook) err := json.NewDecoder(r).Decode(pr) return pr, err } // fixMalformedAvatar is a helper function that fixes an avatar url if malformed // (currently a known bug with forgejo). func fixMalformedAvatar(url string) string { index := strings.Index(url, "///") if index != -1 { return url[index+1:] } index = strings.Index(url, "//avatars/") if index != -1 { return strings.ReplaceAll(url, "//avatars/", "/avatars/") } return url } // expandAvatar is a helper function that converts a relative avatar URL to the // absolute url. func expandAvatar(repo, rawURL string) string { aURL, err := url.Parse(rawURL) if err != nil { return rawURL } if aURL.IsAbs() { // Url is already absolute return aURL.String() } // Resolve to base burl, err := url.Parse(repo) if err != nil { return rawURL } aURL = burl.ResolveReference(aURL) return aURL.String() } // helper function to return matching hooks. func matchingHooks(hooks []*forgejo.Hook, rawURL string) *forgejo.Hook { link, err := url.Parse(rawURL) if err != nil { return nil } for _, hook := range hooks { if val, ok := hook.Config["url"]; ok { hookURL, err := url.Parse(val) if err == nil && hookURL.Host == link.Host { return hook } } } return nil } func convertLabels(from []*forgejo.Label) []string { labels := make([]string, len(from)) for i, label := range from { labels[i] = label.Name } return labels } ================================================ FILE: server/forge/forgejo/helper_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import ( "bytes" "testing" "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_parsePush(t *testing.T) { t.Run("Should parse push hook payload", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) hook, err := parsePush(buf) assert.NoError(t, err) assert.Equal(t, "refs/heads/main", hook.Ref) assert.Equal(t, "ef98532add3b2feb7a137426bba1248724367df5", hook.After) assert.Equal(t, "4b2626259b5a97b6b4eab5e6cca66adb986b672b", hook.Before) assert.Equal(t, "http://forgejo.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5", hook.Compare) assert.Equal(t, "hello-world", hook.Repo.Name) assert.Equal(t, "http://forgejo.golang.org/gordon/hello-world", hook.Repo.HTMLURL) assert.Equal(t, "gordon", hook.Repo.Owner.UserName) assert.Equal(t, "gordon/hello-world", hook.Repo.FullName) assert.Equal(t, "gordon@golang.org", hook.Repo.Owner.Email) assert.True(t, hook.Repo.Private) assert.Equal(t, "gordon@golang.org", hook.Pusher.Email) assert.Equal(t, "gordon", hook.Pusher.UserName) assert.Equal(t, "gordon", hook.Sender.UserName) assert.Equal(t, "http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", hook.Sender.AvatarURL) }) t.Run("Should parse tag hook payload", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookTag) hook, err := parsePush(buf) assert.NoError(t, err) assert.Equal(t, "v1.0.0", hook.Ref) assert.Equal(t, "ef98532add3b2feb7a137426bba1248724367df5", hook.Sha) assert.Equal(t, "hello-world", hook.Repo.Name) assert.Equal(t, "http://forgejo.golang.org/gordon/hello-world", hook.Repo.HTMLURL) assert.Equal(t, "gordon/hello-world", hook.Repo.FullName) assert.Equal(t, "gordon@golang.org", hook.Repo.Owner.Email) assert.Equal(t, "gordon", hook.Repo.Owner.UserName) assert.True(t, hook.Repo.Private) assert.Equal(t, "gordon", hook.Sender.UserName) assert.Equal(t, "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", hook.Sender.AvatarURL) }) t.Run("Should return a Pipeline struct from a push hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) hook, _ := parsePush(buf) pipeline := pipelineFromPush(hook) assert.Equal(t, model.EventPush, pipeline.Event) assert.Equal(t, hook.After, pipeline.Commit) assert.Equal(t, hook.Ref, pipeline.Ref) assert.Equal(t, hook.Commits[0].URL, pipeline.ForgeURL) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, hook.Commits[0].Message, pipeline.Message) assert.Equal(t, "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", pipeline.Avatar) assert.Equal(t, hook.Sender.UserName, pipeline.Author) assert.Equal(t, []string{"CHANGELOG.md", "app/controller/application.rb"}, pipeline.ChangedFiles) }) t.Run("Should return a Repo struct from a push hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) hook, _ := parsePush(buf) repo := toRepo(hook.Repo) assert.Equal(t, hook.Repo.Name, repo.Name) assert.Equal(t, hook.Repo.Owner.UserName, repo.Owner) assert.Equal(t, "gordon/hello-world", repo.FullName) assert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL) }) t.Run("Should return a Pipeline struct from a tag hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookTag) hook, _ := parsePush(buf) pipeline := pipelineFromTag(hook) assert.Equal(t, model.EventTag, pipeline.Event) assert.Equal(t, hook.Sha, pipeline.Commit) assert.Equal(t, "refs/tags/v1.0.0", pipeline.Ref) assert.Empty(t, pipeline.Branch) assert.Equal(t, "http://forgejo.golang.org/gordon/hello-world/src/tag/v1.0.0", pipeline.ForgeURL) assert.Equal(t, "created tag v1.0.0", pipeline.Message) }) } func Test_parsePullRequest(t *testing.T) { t.Run("Should parse pull_request hook payload", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) hook, err := parsePullRequest(buf) assert.NoError(t, err) assert.Equal(t, "opened", hook.Action) assert.Equal(t, int64(1), hook.Number) assert.Equal(t, "hello-world", hook.Repo.Name) assert.Equal(t, "http://forgejo.golang.org/gordon/hello-world", hook.Repo.HTMLURL) assert.Equal(t, "gordon/hello-world", hook.Repo.FullName) assert.Equal(t, "gordon@golang.org", hook.Repo.Owner.Email) assert.Equal(t, "gordon", hook.Repo.Owner.UserName) assert.True(t, hook.Repo.Private) assert.Equal(t, "gordon", hook.Sender.UserName) assert.Equal(t, "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", hook.Sender.AvatarURL) assert.Equal(t, "Update the README with new information", hook.PullRequest.Title) assert.Equal(t, "please merge", hook.PullRequest.Body) assert.Equal(t, forgejo.StateOpen, hook.PullRequest.State) assert.Equal(t, "gordon", hook.PullRequest.Poster.UserName) assert.Equal(t, "main", hook.PullRequest.Base.Name) assert.Equal(t, "main", hook.PullRequest.Base.Ref) assert.Equal(t, "feature/changes", hook.PullRequest.Head.Name) assert.Equal(t, "feature/changes", hook.PullRequest.Head.Ref) }) t.Run("Should return a Pipeline struct from a pull_request hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) hook, _ := parsePullRequest(buf) pipeline := pipelineFromPullRequest(hook) assert.Equal(t, model.EventPull, pipeline.Event) assert.Equal(t, hook.PullRequest.Head.Sha, pipeline.Commit) assert.Equal(t, "refs/pull/1/head", pipeline.Ref) assert.Equal(t, "http://forgejo.golang.org/gordon/hello-world/pull/1", pipeline.ForgeURL) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, "feature/changes:main", pipeline.Refspec) assert.Equal(t, hook.PullRequest.Title, pipeline.Message) assert.Equal(t, "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", pipeline.Avatar) assert.Equal(t, hook.PullRequest.Poster.UserName, pipeline.Author) }) t.Run("Should return a Repo struct from a pull_request hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) hook, _ := parsePullRequest(buf) repo := toRepo(hook.Repo) assert.Equal(t, hook.Repo.Name, repo.Name) assert.Equal(t, hook.Repo.Owner.UserName, repo.Owner) assert.Equal(t, "gordon/hello-world", repo.FullName) assert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL) }) } func Test_toPerm(t *testing.T) { perms := []forgejo.Permission{ { Admin: true, Push: true, Pull: true, }, { Admin: true, Push: true, Pull: false, }, { Admin: true, Push: false, Pull: false, }, } for _, from := range perms { perm := toPerm(&from) assert.Equal(t, from.Pull, perm.Pull) assert.Equal(t, from.Push, perm.Push) assert.Equal(t, from.Admin, perm.Admin) } } func Test_toTeam(t *testing.T) { from := &forgejo.Organization{ UserName: "woodpecker", AvatarURL: "/avatars/1", } to := toTeam(from, "http://localhost:80") assert.Equal(t, from.UserName, to.Login) assert.Equal(t, "http://localhost:80/avatars/1", to.Avatar) } func Test_toRepo(t *testing.T) { from := forgejo.Repository{ FullName: "gophers/hello-world", Owner: &forgejo.User{ UserName: "gordon", AvatarURL: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, CloneURL: "http://forgejo.golang.org/gophers/hello-world.git", HTMLURL: "http://forgejo.golang.org/gophers/hello-world", Private: true, DefaultBranch: "main", Permissions: &forgejo.Permission{Admin: true}, } repo := toRepo(&from) assert.Equal(t, from.FullName, repo.FullName) assert.Equal(t, from.Owner.UserName, repo.Owner) assert.Equal(t, "hello-world", repo.Name) assert.Equal(t, "main", repo.Branch) assert.Equal(t, from.HTMLURL, repo.ForgeURL) assert.Equal(t, from.CloneURL, repo.Clone) assert.Equal(t, from.Owner.AvatarURL, repo.Avatar) assert.Equal(t, from.Private, repo.IsSCMPrivate) assert.True(t, repo.Perm.Admin) } func Test_fixMalformedAvatar(t *testing.T) { urls := []struct { Before string After string }{ { "http://forgejo.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, { "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, { "http://forgejo.golang.org/avatars/1", "http://forgejo.golang.org/avatars/1", }, { "http://forgejo.golang.org//avatars/1", "http://forgejo.golang.org/avatars/1", }, } for _, url := range urls { got := fixMalformedAvatar(url.Before) assert.Equal(t, url.After, got) } } func Test_expandAvatar(t *testing.T) { urls := []struct { Before string After string }{ { "/avatars/1", "http://forgejo.io/avatars/1", }, { "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, { "/forgejo/avatars/2", "http://forgejo.io/forgejo/avatars/2", }, } repo := "http://forgejo.io/foo/bar" for _, url := range urls { got := expandAvatar(repo, url.Before) assert.Equal(t, url.After, got) } } ================================================ FILE: server/forge/forgejo/parse.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import ( "io" "net/http" "slices" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( hookEvent = "X-Forgejo-Event" hookPush = "push" hookCreated = "create" hookPullRequest = "pull_request" hookRelease = "release" actionOpen = "opened" actionSync = "synchronized" actionClose = "closed" actionEdited = "edited" actionLabelUpdate = "label_updated" actionLabelCleared = "label_cleared" actionMilestoned = "milestoned" actionDeMilestoned = "demilestoned" actionAssigned = "assigned" actionUnAssigned = "unassigned" actionReopen = "reopened" refBranch = "branch" refTag = "tag" ) var actionList = []string{ actionOpen, actionSync, actionClose, actionEdited, actionLabelUpdate, actionMilestoned, actionDeMilestoned, actionLabelCleared, actionAssigned, actionUnAssigned, actionReopen, } func supportedAction(action string) bool { return slices.Contains(actionList, action) } // parseHook parses a Forgejo hook from an http.Request and returns // Repo and Pipeline detail. If a hook type is unsupported nil values are returned. func parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) { hookType := r.Header.Get(hookEvent) switch hookType { case hookPush: return parsePushHook(r.Body) case hookCreated: return parseCreatedHook(r.Body) case hookPullRequest: return parsePullRequestHook(r.Body) case hookRelease: return parseReleaseHook(r.Body) } log.Debug().Msgf("unsupported hook type: '%s'", hookType) return nil, nil, &types.ErrIgnoreEvent{Event: hookType} } // parsePushHook parses a push hook and returns the Repo and Pipeline details. // If the commit type is unsupported nil values are returned. func parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) { push, err := parsePush(payload) if err != nil { return nil, nil, err } // ignore push events for tags if strings.HasPrefix(push.Ref, "refs/tags/") { return nil, nil, nil } // TODO is this even needed? if push.RefType == refBranch { return nil, nil, nil } repo = toRepo(push.Repo) pipeline = pipelineFromPush(push) return repo, pipeline, err } // parseCreatedHook parses a push hook and returns the Repo and Pipeline details. // If the commit type is unsupported nil values are returned. func parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) { push, err := parsePush(payload) if err != nil { return nil, nil, err } if push.RefType != refTag { return nil, nil, nil } repo = toRepo(push.Repo) pipeline = pipelineFromTag(push) return repo, pipeline, nil } // parsePullRequestHook parses a pull_request hook and returns the Repo and Pipeline details. func parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) { var ( repo *model.Repo pipeline *model.Pipeline ) pr, err := parsePullRequest(payload) if err != nil { return nil, nil, err } // Only trigger pipelines for supported event types if !supportedAction(pr.Action) { log.Debug().Msgf("pull_request action is '%s'. Only '%s' are supported", pr.Action, strings.Join(actionList, "', '")) return nil, nil, nil } repo = toRepo(pr.Repo) pipeline = pipelineFromPullRequest(pr) // all other actions return the state of labels after the actions where done ... so we should too if pr.Action == actionLabelCleared { pipeline.PullRequestLabels = []string{} } if pr.Action == actionDeMilestoned { pipeline.PullRequestMilestone = "" } for i := range pipeline.EventReason { pipeline.EventReason[i] = common.NormalizeEventReason(pipeline.EventReason[i]) } return repo, pipeline, err } // parseReleaseHook parses a release hook and returns the Repo and Pipeline details. func parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) { var ( repo *model.Repo pipeline *model.Pipeline ) release, err := parseRelease(payload) if err != nil { return nil, nil, err } repo = toRepo(release.Repo) pipeline = pipelineFromRelease(release) return repo, pipeline, err } ================================================ FILE: server/forge/forgejo/parse_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import ( "bytes" "net/http" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestForgejoParser(t *testing.T) { tests := []struct { name string data string event string err error repo *model.Repo pipe *model.Pipeline }{ { name: "should ignore unsupported hook events", data: fixtures.HookPullRequest, event: "issues", err: &types.ErrIgnoreEvent{}, }, { name: "push event should handle a push hook", data: fixtures.HookPushBranch, event: "push", repo: &model.Repo{ ForgeRemoteID: "50820", Owner: "meisam", Name: "woodpecktester", FullName: "meisam/woodpecktester", Avatar: "https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e", ForgeURL: "https://codeberg.org/meisam/woodpecktester", Clone: "https://codeberg.org/meisam/woodpecktester.git", CloneSSH: "git@codeberg.org:meisam/woodpecktester.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "push", Commit: "28c3613ae62640216bea5e7dc71aa65356e4298b", Branch: "fdsafdsa", Ref: "refs/heads/fdsafdsa", Message: "Delete '.woodpecker/.check.yml'\n", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@obermui.de", ForgeURL: "https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b", ChangedFiles: []string{".woodpecker/.check.yml"}, }, }, { name: "push event should extract repository and pipeline details", data: fixtures.HookPush, event: "push", repo: &model.Repo{ ForgeRemoteID: "1", Owner: "gordon", Name: "hello-world", FullName: "gordon/hello-world", Avatar: "http://forgejo.golang.org/gordon/hello-world", ForgeURL: "http://forgejo.golang.org/gordon/hello-world", Clone: "http://forgejo.golang.org/gordon/hello-world.git", CloneSSH: "git@forgejo.golang.org:gordon/hello-world.git", IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "gordon", Event: "push", Commit: "ef98532add3b2feb7a137426bba1248724367df5", Branch: "main", Ref: "refs/heads/main", Message: "bump\n", Sender: "gordon", Avatar: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", Email: "gordon@golang.org", ForgeURL: "http://forgejo.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5", ChangedFiles: []string{"CHANGELOG.md", "app/controller/application.rb"}, }, }, { name: "push event should handle multi commit push", data: fixtures.HookPushMulti, event: "push", repo: &model.Repo{ ForgeRemoteID: "6", Owner: "Test-CI", Name: "multi-line-secrets", FullName: "Test-CI/multi-line-secrets", Avatar: "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets", Clone: "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", CloneSSH: "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", Branch: "main", Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "test-user", Event: "push", Commit: "29be01c073851cf0db0c6a466e396b725a670453", Branch: "main", Ref: "refs/heads/main", Message: "add some text\n", Sender: "test-user", Avatar: "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", Email: "test@noreply.localhost", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453", ChangedFiles: []string{"aaa", "aa"}, }, }, { name: "tag event should handle a tag hook", data: fixtures.HookTag, event: "create", repo: &model.Repo{ ForgeRemoteID: "12", Owner: "gordon", Name: "hello-world", FullName: "gordon/hello-world", Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", ForgeURL: "http://forgejo.golang.org/gordon/hello-world", Clone: "http://forgejo.golang.org/gordon/hello-world.git", CloneSSH: "git@forgejo.golang.org:gordon/hello-world.git", Branch: "main", IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "gordon", Event: "tag", Commit: "ef98532add3b2feb7a137426bba1248724367df5", Ref: "refs/tags/v1.0.0", Message: "created tag v1.0.0", Sender: "gordon", Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", Email: "gordon@golang.org", ForgeURL: "http://forgejo.golang.org/gordon/hello-world/src/tag/v1.0.0", }, }, { name: "pull-request events should handle a PR hook when PR got created", data: fixtures.HookPullRequest, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "35129377", Owner: "gordon", Name: "hello-world", FullName: "gordon/hello-world", Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", ForgeURL: "http://forgejo.golang.org/gordon/hello-world", Clone: "https://forgejo.golang.org/gordon/hello-world.git", CloneSSH: "", Branch: "main", IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "gordon", Event: "pull_request", Commit: "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", Branch: "main", Ref: "refs/pull/1/head", Refspec: "feature/changes:main", Title: "Update the README with new information", Message: "Update the README with new information", Sender: "gordon", Avatar: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", Email: "gordon@golang.org", ForgeURL: "http://forgejo.golang.org/gordon/hello-world/pull/1", PullRequestLabels: []string{}, }, }, { name: "pull-request reopen events should handle a PR as it was first created", data: fixtures.HookPullRequestReopened, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, IsSCMPrivate: false, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request", Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR hook when PR got updated", data: fixtures.HookPullRequestUpdated, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "6", Owner: "Test-CI", Name: "multi-line-secrets", FullName: "Test-CI/multi-line-secrets", Avatar: "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets", Clone: "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", CloneSSH: "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", Branch: "main", PREnabled: true, IsSCMPrivate: false, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "test", Event: "pull_request", Commit: "788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25", Branch: "main", Ref: "refs/pull/2/head", Refspec: "test-patch-1:main", Title: "New Pull", Message: "New Pull", Sender: "test", Avatar: "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", Email: "test@noreply.localhost", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", PullRequestLabels: []string{ "Kind/Bug", "Kind/Security", }, }, }, { name: "pull-request events should handle a PR edited hook when PR got edited", data: fixtures.HookPullRequestEdited, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "46534", Owner: "anbraten", Name: "test-repo", FullName: "anbraten/test-repo", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", ForgeURL: "https://forgejo.com/anbraten/test-repo", Clone: "https://forgejo.com/anbraten/test-repo.git", CloneSSH: "git@forgejo.com:anbraten/test-repo.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "pull_request_metadata", EventReason: []string{"edited"}, Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", Branch: "main", Ref: "refs/pull/1/head", Refspec: "anbraten-patch-1:main", Title: "Adjust file", Message: "Adjust file", Sender: "anbraten", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", Email: "anbraten@sender.forgejo.com", ForgeURL: "https://forgejo.com/anbraten/test-repo/pulls/1", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR closed hook when PR got closed", data: fixtures.HookPullRequestClosed, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "46534", Owner: "anbraten", Name: "test-repo", FullName: "anbraten/test-repo", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", ForgeURL: "https://forgejo.com/anbraten/test-repo", Clone: "https://forgejo.com/anbraten/test-repo.git", CloneSSH: "git@forgejo.com:anbraten/test-repo.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "pull_request_closed", Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", Branch: "main", Ref: "refs/pull/1/head", Refspec: "anbraten-patch-1:main", Title: "Adjust file", Message: "Adjust file", Sender: "anbraten", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", Email: "anbraten@sender.forgejo.com", ForgeURL: "https://forgejo.com/anbraten/test-repo/pulls/1", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR closed hook when PR was merged", data: fixtures.HookPullRequestMerged, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "46534", Owner: "anbraten", Name: "test-repo", FullName: "anbraten/test-repo", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", ForgeURL: "https://forgejo.com/anbraten/test-repo", Clone: "https://forgejo.com/anbraten/test-repo.git", CloneSSH: "git@forgejo.com:anbraten/test-repo.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "pull_request_closed", Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", Branch: "main", Ref: "refs/pull/1/head", Refspec: "anbraten-patch-1:main", Title: "Adjust file", Message: "Adjust file", Sender: "anbraten", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", Email: "anbraten@noreply.forgejo.com", ForgeURL: "https://forgejo.com/anbraten/test-repo/pulls/1", PullRequestLabels: []string{}, }, }, { name: "release events should handle release hook", data: fixtures.HookRelease, event: "release", repo: &model.Repo{ ForgeRemoteID: "77", Owner: "anbraten", Name: "demo", FullName: "anbraten/demo", Avatar: "https://git.xxx/user/avatar/anbraten/-1", ForgeURL: "https://git.xxx/anbraten/demo", Clone: "https://git.xxx/anbraten/demo.git", CloneSSH: "ssh://git@git.xxx:22/anbraten/demo.git", Branch: "main", PREnabled: true, IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "release", Branch: "main", Ref: "refs/tags/0.0.5", Message: "created release Version 0.0.5", Sender: "anbraten", Avatar: "https://git.xxx/user/avatar/anbraten/-1", Email: "anbraten@noreply.xxx", ForgeURL: "https://git.xxx/anbraten/demo/releases/tag/0.0.5", }, }, { name: "pull-request events should handle a PR assignees added hook when assignees are added", data: fixtures.HookPullRequestAssigneesAdded, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"assigned"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{}, ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR milestone added hook when milestone is added", data: fixtures.HookPullRequestMilestoneAdded, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"milestoned"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{}, PullRequestMilestone: "mile v2", ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR label updated hook when labels are updated", data: fixtures.HookPullRequestLabelAdded, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"label_updated"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{"Kind/Documentation", "Kind/Enhancement"}, PullRequestMilestone: "mile v2", ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR assignee cleared hook when assignee is removed", data: fixtures.HookPullRequestAssigneeCleared, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"unassigned"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{"Kind/Documentation", "Kind/Enhancement"}, PullRequestMilestone: "mile v2", ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR milestone changed hook when milestone is changed", data: fixtures.HookPullRequestMilestoneChanged, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"milestoned"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{"Kind/Documentation", "Kind/Enhancement"}, PullRequestMilestone: "mile v2", ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR labels updated hook when labels are updated", data: fixtures.HookPullRequestLabelsUpdated, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"label_updated"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{"Kind/Enhancement", "Kind/Testing"}, PullRequestMilestone: "mile v1", ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR labels cleared hook when labels are cleared", data: fixtures.HookPullRequestLabelsCleared, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"label_cleared"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{}, PullRequestMilestone: "mile v1", ChangedFiles: nil, }, }, { name: "pull-request events should handle a PR milestone cleared hook when milestone is removed", data: fixtures.HookPullRequestMilestoneCleared, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "ssh://git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request_metadata", EventReason: []string{"demilestoned"}, Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{}, ChangedFiles: nil, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, "/api/hook", bytes.NewBufferString(tc.data)) req.Header = http.Header{} req.Header.Set(hookEvent, tc.event) r, p, err := parseHook(req) if tc.err != nil { assert.ErrorIs(t, err, tc.err) } else if assert.NoError(t, err) { assert.EqualValues(t, tc.repo, r) p.Timestamp = 0 assert.EqualValues(t, tc.pipe, p) } }) } } ================================================ FILE: server/forge/forgejo/types.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forgejo import "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v3" type pushHook struct { Sha string `json:"sha"` Ref string `json:"ref"` Before string `json:"before"` After string `json:"after"` Compare string `json:"compare_url"` RefType string `json:"ref_type"` Pusher *forgejo.User `json:"pusher"` Repo *forgejo.Repository `json:"repository"` Commits []forgejo.PayloadCommit `json:"commits"` HeadCommit forgejo.PayloadCommit `json:"head_commit"` Sender *forgejo.User `json:"sender"` } type pullRequestHook struct { Action string `json:"action"` Number int64 `json:"number"` PullRequest *forgejo.PullRequest `json:"pull_request"` Repo *forgejo.Repository `json:"repository"` Sender *forgejo.User `json:"sender"` } type releaseHook struct { Action string `json:"action"` Repo *forgejo.Repository `json:"repository"` Sender *forgejo.User `json:"sender"` Release *forgejo.Release } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequest.json ================================================ { "action": "opened", "number": 1, "pull_request": { "html_url": "http://gitea.golang.org/gordon/hello-world/pull/1", "state": "open", "title": "Update the README with new information", "body": "please merge", "user": { "id": 1, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" }, "base": { "label": "main", "ref": "main", "sha": "9353195a19e45482665306e466c832c46560532d" }, "head": { "label": "feature/changes", "ref": "feature/changes", "sha": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c" } }, "repository": { "id": 35129377, "name": "hello-world", "full_name": "gordon/hello-world", "owner": { "id": 1, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" }, "private": true, "html_url": "http://gitea.golang.org/gordon/hello-world", "clone_url": "https://gitea.golang.org/gordon/hello-world.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true } }, "sender": { "id": 1, "login": "gordon", "username": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" } } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestAddLabel.json ================================================ { "action": "label_updated", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" }, { "id": 297, "name": "help wanted", "exclusive": false, "is_archived": false, "color": "128a0c", "description": "Need some help", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297" } ], "milestone": null, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestAddMile.json ================================================ { "action": "milestoned", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" }, { "id": 297, "name": "help wanted", "exclusive": false, "is_archived": false, "color": "128a0c", "description": "Need some help", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297" } ], "milestone": { "id": 277, "title": "new mile", "state": "open", "open_issues": 1, "closed_issues": 0, "closed_at": null, "due_on": null }, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestAddReviewRequest.json ================================================ { "action": "review_requested", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [], "milestone": null, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestAssigneesAdded.json ================================================ { "action": "assigned", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 1, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" } ], "milestone": null, "assignee": { "id": 6, "login": "lilly", "full_name": "Lilly Apple", "email": "lilly@noreply.example.org", "avatar_url": "https://gitea.com/avatars/a02de81c8ee997fc0aff3c6ebe18841903a75fd6", "html_url": "https://gitea.com/lilly", "visibility": "public", "username": "lilly" }, "assignees": [ { "id": 6, "login": "lilly", "full_name": "Lilly Apple", "email": "lilly@noreply.example.org", "avatar_url": "https://gitea.com/avatars/a02de81c8ee997fc0aff3c6ebe18841903a75fd6", "html_url": "https://gitea.com/lilly", "visibility": "public", "username": "lilly" } ], "requested_reviewers": [ { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "draft": false, "is_locked": false, "comments": 0, "review_comments": 0, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56" }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" }, "sender": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestAssigneesRemoved.json ================================================ { "action": "unassigned", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 1, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" } ], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": [ { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "mergeable": true, "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "avatar_url": "", "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "avatar_url": "", "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56" }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "avatar_url": "", "object_format_name": "sha1" }, "sender": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestChangeBody.json ================================================ { "action": "edited", "number": 7, "changes": { "body": { "from": "" } }, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [], "milestone": null, "assignees": null, "requested_reviewers": null, "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestChangeLabel.json ================================================ { "action": "label_updated", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" } ], "milestone": null, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestChangeMile.json ================================================ { "action": "milestoned", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" }, { "id": 297, "name": "help wanted", "exclusive": false, "is_archived": false, "color": "128a0c", "description": "Need some help", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297" } ], "milestone": { "id": 273, "title": "closed mile", "state": "closed", "open_issues": 1, "closed_issues": 0, "closed_at": "2025-05-28T03:13:46+02:00", "due_on": null }, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestChangeTitle.json ================================================ { "action": "edited", "number": 7, "changes": { "title": { "from": "Update .woodpecker.yml" } }, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "last_login": "0001-01-01T00:00:00Z", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "Edit pull title :D", "body": "", "labels": [], "milestone": null, "assignees": null, "requested_reviewers": null, "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestClosed.json ================================================ { "action": "closed", "number": 1, "pull_request": { "id": 62112, "url": "https://gitea.com/anbraten/test-repo/pulls/1", "number": 1, "user": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "title": "Adjust file", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": null, "state": "closed", "is_locked": false, "comments": 0, "html_url": "https://gitea.com/anbraten/test-repo/pulls/1", "diff_url": "https://gitea.com/anbraten/test-repo/pulls/1.diff", "patch_url": "https://gitea.com/anbraten/test-repo/pulls/1.patch", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://gitea.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://gitea.com/anbraten/test-repo", "url": "https://gitea.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@gitea.com:anbraten/test-repo.git", "clone_url": "https://gitea.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "head": { "label": "anbraten-patch-1", "ref": "anbraten-patch-1", "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://gitea.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://gitea.com/anbraten/test-repo", "url": "https://gitea.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@gitea.com:anbraten/test-repo.git", "clone_url": "https://gitea.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "due_date": null, "created_at": "2023-12-05T18:06:38Z", "updated_at": "2023-12-05T18:06:43Z", "closed_at": "2023-12-05T18:06:43Z", "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@repo.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://gitea.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://gitea.com/anbraten/test-repo", "url": "https://gitea.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@gitea.com:anbraten/test-repo.git", "clone_url": "https://gitea.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null }, "sender": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@sender.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestMerged.json ================================================ { "action": "closed", "number": 1, "pull_request": { "id": 62112, "url": "https://gitea.com/anbraten/test-repo/pulls/1", "number": 1, "user": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "title": "Adjust file", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": null, "state": "closed", "is_locked": false, "comments": 1, "html_url": "https://gitea.com/anbraten/test-repo/pulls/1", "diff_url": "https://gitea.com/anbraten/test-repo/pulls/1.diff", "patch_url": "https://gitea.com/anbraten/test-repo/pulls/1.patch", "mergeable": true, "merged": true, "merged_at": "2023-12-05T18:35:31Z", "merge_commit_sha": "f2440f050054df0f8ecabcace648f1683509064c", "merged_by": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "f2440f050054df0f8ecabcace648f1683509064c", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://gitea.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://gitea.com/anbraten/test-repo", "url": "https://gitea.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@gitea.com:anbraten/test-repo.git", "clone_url": "https://gitea.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "head": { "label": "anbraten-patch-1", "ref": "anbraten-patch-1", "sha": "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", "repo_id": 46534, "repo": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://gitea.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://gitea.com/anbraten/test-repo", "url": "https://gitea.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@gitea.com:anbraten/test-repo.git", "clone_url": "https://gitea.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": false, "push": false, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null } }, "merge_base": "068aee163ffd44eef28a7f9ebd43e2c01774f0fa", "due_date": null, "created_at": "2023-12-05T18:06:38Z", "updated_at": "2023-12-05T18:35:31Z", "closed_at": "2023-12-05T18:35:31Z", "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 46534, "owner": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "name": "test-repo", "full_name": "anbraten/test-repo", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 26, "language": "", "languages_url": "https://gitea.com/api/v1/repos/anbraten/test-repo/languages", "html_url": "https://gitea.com/anbraten/test-repo", "url": "https://gitea.com/api/v1/repos/anbraten/test-repo", "link": "", "ssh_url": "git@gitea.com:anbraten/test-repo.git", "clone_url": "https://gitea.com/anbraten/test-repo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 1, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-12-05T18:03:55Z", "updated_at": "2023-12-05T18:06:29Z", "archived_at": "1970-01-01T00:00:00Z", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": false, "has_actions": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null }, "sender": { "id": 26907, "login": "anbraten", "login_name": "", "full_name": "", "email": "anbraten@noreply.gitea.com", "avatar_url": "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2021-07-19T23:21:52Z", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 1, "username": "anbraten" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestRemoveLabel.json ================================================ { "action": "label_cleared", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" }, { "id": 297, "name": "help wanted", "exclusive": false, "is_archived": false, "color": "128a0c", "description": "Need some help", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297" } ], "milestone": null, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestRemoveMile.json ================================================ { "action": "demilestoned", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 21, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "created": "2018-01-25T14:38:19+01:00", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [ { "id": 285, "name": "bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/285" }, { "id": 297, "name": "help wanted", "exclusive": false, "is_archived": false, "color": "128a0c", "description": "Need some help", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/labels/297" } ], "milestone": { "id": 273, "title": "closed mile", "state": "closed", "open_issues": 1, "closed_issues": 0, "closed_at": "2025-05-28T03:13:46+02:00", "due_on": null }, "assignees": null, "requested_reviewers": [ { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "object_format_name": "sha1" } }, "merge_base": "a40211c506550ebd79633d84e913dafa184c6d56", "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "private": false, "has_pull_requests": true, "languages_url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci/languages", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "link": "", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "object_format_name": "sha1" }, "sender": { "id": 8765, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "created": "2023-05-23T15:17:35+02:00", "visibility": "public", "username": "a_nice_user" }, "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestReopened.json ================================================ { "action": "reopened", "number": 1, "pull_request": { "id": 701944, "url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "number": 1, "user": { "id": 2628, "login": "6543", "login_name": "", "source_id": 0, "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "language": "en-US", "is_admin": false, "last_login": "2025-08-05T17:04:55+02:00", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": true, "prohibit_login": false, "location": "", "pronouns": "", "website": "https://mh.obermui.de", "description": "\u003ca href=\"https://matrix.to/#/@marddl:obermui.de\" rel=\"nofollow\"\u003e\u003cimg src=\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\"\u003e\u003c/a\u003e\r\n\u003ca rel=\"me\" href=\"https://chaos.social/@6543\"\u003eMastodon\u003c/a\u003e", "visibility": "public", "followers_count": 46, "following_count": 33, "starred_repos_count": 92, "username": "6543" }, "title": "Some ned more AAAA", "body": "", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": [], "requested_reviewers_teams": [], "state": "open", "draft": false, "is_locked": false, "comments": 0, "review_comments": 1, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1", "diff_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1.diff", "patch_url": "https://codeberg.org/test_it/test_ci_thing/pulls/1.patch", "mergeable": false, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "allow_maintainer_edit": false, "base": { "label": "main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "login_name": "", "source_id": 0, "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-04-02T15:13:07+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "", "description": "the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "test_it" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 34, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages", "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "link": "", "ssh_url": "git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "original_url": "", "website": "", "stars_count": 1, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-08-27T03:32:56+02:00", "updated_at": "2025-07-29T16:45:07+02:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "wiki_branch": "master", "globally_editable_wiki": false, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": true, "has_actions": false, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_fast_forward_only_merge": false, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "default_update_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "sha1", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null, "topics": [] } }, "head": { "label": "6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "repo_id": 138564, "repo": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "login_name": "", "source_id": 0, "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-04-02T15:13:07+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "", "description": "the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "test_it" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 34, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages", "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "link": "", "ssh_url": "git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "original_url": "", "website": "", "stars_count": 1, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-08-27T03:32:56+02:00", "updated_at": "2025-07-29T16:45:07+02:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "wiki_branch": "master", "globally_editable_wiki": false, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": true, "has_actions": false, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_fast_forward_only_merge": false, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "default_update_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "sha1", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null, "topics": [] } }, "merge_base": "67012991d6c69b1c58378346fca366b864d8d1a1", "due_date": null, "created_at": "2025-07-29T16:45:09+02:00", "updated_at": "2025-08-05T17:06:49+02:00", "closed_at": null, "pin_order": 0, "flow": 0 }, "requested_reviewer": null, "repository": { "id": 138564, "owner": { "id": 90470, "login": "test_it", "login_name": "", "source_id": 0, "full_name": "", "email": "", "avatar_url": "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", "html_url": "https://codeberg.org/test_it", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-04-02T15:13:07+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "", "description": "the [link](https://stackoverflow.com/questions/4212503/how-can-i-set-the-request-header-for-curl#4212535) us curl-ish", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "test_it" }, "name": "test_ci_thing", "full_name": "test_it/test_ci_thing", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 34, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing/languages", "html_url": "https://codeberg.org/test_it/test_ci_thing", "url": "https://codeberg.org/api/v1/repos/test_it/test_ci_thing", "link": "", "ssh_url": "git@codeberg.org/test_it/test_ci_thing.git", "clone_url": "https://codeberg.org/test_it/test_ci_thing.git", "original_url": "", "website": "", "stars_count": 1, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2023-08-27T03:32:56+02:00", "updated_at": "2025-07-29T16:45:07+02:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "wiki_branch": "master", "globally_editable_wiki": false, "has_pull_requests": true, "has_projects": true, "has_releases": true, "has_packages": true, "has_actions": false, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "allow_fast_forward_only_merge": false, "allow_rebase_update": true, "default_delete_branch_after_merge": false, "default_merge_style": "merge", "default_allow_maintainer_edit": false, "default_update_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "sha1", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null, "topics": [] }, "sender": { "id": 2628, "login": "6543", "login_name": "", "source_id": 0, "full_name": "", "email": "6543@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "html_url": "https://codeberg.org/6543", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "pronouns": "", "website": "https://mh.obermui.de", "description": "\u003ca href=\"https://matrix.to/#/@marddl:obermui.de\" rel=\"nofollow\"\u003e\u003cimg src=\"https://codeberg.org/6543/content/raw/branch/main/matrix-logo.png\"\u003e\u003c/a\u003e\r\n\u003ca rel=\"me\" href=\"https://chaos.social/@6543\"\u003eMastodon\u003c/a\u003e", "visibility": "public", "followers_count": 46, "following_count": 33, "starred_repos_count": 92, "username": "6543" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestReviewAck.json ================================================ { "action": "reviewed", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 1, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "description": "", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [], "milestone": null, "assignees": null, "requested_reviewers": [ { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "comments": 0, "review_comments": 1, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } } }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" }, "sender": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "commit_id": "", "review": { "type": "pull_request_review_approved", "content": "juhu thats a great idea" } } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestReviewComment.json ================================================ { "action": "reviewed", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 1, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "description": "", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [], "milestone": null, "assignees": null, "requested_reviewers": [ { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "comments": 0, "review_comments": 3, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } } }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" }, "sender": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "commit_id": "", "review": { "type": "pull_request_review_comment", "content": "and somethimes you have to comment" } } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestReviewDeny.json ================================================ { "action": "reviewed", "number": 7, "pull_request": { "id": 3779, "url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "number": 7, "user": { "id": 1, "login": "jony", "full_name": "Jony", "email": "jony@noreply.example.org", "avatar_url": "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", "html_url": "https://gitea.com/jony", "description": "", "visibility": "public", "username": "jony" }, "title": "somepull", "body": "wow aaa new pulll body", "labels": [], "milestone": null, "assignee": null, "assignees": null, "requested_reviewers": [ { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" } ], "state": "open", "draft": false, "is_locked": false, "comments": 0, "review_comments": 2, "additions": 1, "deletions": 0, "changed_files": 1, "html_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", "diff_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.diff", "patch_url": "https://gitea.com/a_nice_user/hello_world_ci/pulls/7.patch", "base": { "label": "main", "ref": "main", "sha": "a40211c506550ebd79633d84e913dafa184c6d56", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } }, "head": { "label": "jony-patch-1", "ref": "jony-patch-1", "sha": "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", "repo_id": 1234, "repo": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": false, "push": false, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" } }, "due_date": null, "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 1234, "owner": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@me.mail", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "name": "hello_world_ci", "full_name": "a_nice_user/hello_world_ci", "description": "", "html_url": "https://gitea.com/a_nice_user/hello_world_ci", "url": "https://gitea.com/api/v1/repos/a_nice_user/hello_world_ci", "ssh_url": "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", "clone_url": "https://gitea.com/a_nice_user/hello_world_ci.git", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_pull_requests": true, "object_format_name": "sha1" }, "sender": { "id": 349, "login": "a_nice_user", "full_name": "Nice User", "email": "a_nice_user@noreply.example.org", "avatar_url": "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", "html_url": "https://gitea.com/a_nice_user", "visibility": "public", "username": "a_nice_user" }, "commit_id": "", "review": { "type": "pull_request_review_rejected", "content": "I decided otherwhies :O" } } ================================================ FILE: server/forge/gitea/fixtures/HookPullRequestUpdated.json ================================================ { "action": "synchronized", "number": 2, "pull_request": { "id": 2, "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", "number": 2, "user": { "id": 1, "login": "test", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "visibility": "public", "username": "test" }, "title": "New Pull", "body": "create an awesome pull", "labels": [ { "id": 8, "name": "Kind/Bug", "exclusive": false, "is_archived": false, "color": "ee0701", "description": "Something is not working", "url": "http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/8" }, { "id": 11, "name": "Kind/Security", "exclusive": false, "is_archived": false, "color": "9c27b0", "description": "This is security issue", "url": "http://100.106.226.9:3000/api/v1/repos/Test-CI/multi-line-secrets/labels/11" } ], "milestone": null, "assignees": null, "requested_reviewers": null, "state": "open", "is_locked": false, "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", "diff_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.diff", "patch_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2.patch", "mergeable": true, "merged": false, "merged_at": null, "merge_commit_sha": null, "merged_by": null, "base": { "label": "main", "ref": "main", "sha": "29be01c073851cf0db0c6a466e396b725a670453", "repo_id": 6 }, "head": { "label": "test-patch-1", "ref": "test-patch-1", "sha": "788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25", "repo_id": 6 }, "merge_base": "29be01c073851cf0db0c6a466e396b725a670453", "due_date": null, "created_at": "2024-02-22T01:38:39+01:00", "updated_at": "2024-02-22T01:42:03+01:00", "closed_at": null, "pin_order": 0 }, "requested_reviewer": null, "repository": { "id": 6, "owner": { "id": 2, "login": "Test-CI", "login_name": "", "full_name": "", "email": "", "avatar_url": "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:48+02:00", "prohibit_login": false, "visibility": "public", "username": "Test-CI" }, "name": "multi-line-secrets", "full_name": "Test-CI/multi-line-secrets", "description": "", "private": false, "languages_url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages", "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets", "url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets", "link": "", "ssh_url": "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", "clone_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", "original_url": "", "default_branch": "main", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_pull_requests": true, "avatar_url": "", "internal": false, "mirror_interval": "", "object_format_name": "" }, "sender": { "id": 1, "login": "test", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "visibility": "public", "username": "test" }, "commit_id": "", "review": null } ================================================ FILE: server/forge/gitea/fixtures/HookPush.json ================================================ { "ref": "refs/heads/main", "before": "4b2626259b5a97b6b4eab5e6cca66adb986b672b", "after": "ef98532add3b2feb7a137426bba1248724367df5", "compare_url": "http://gitea.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5", "commits": [ { "id": "ef98532add3b2feb7a137426bba1248724367df5", "message": "bump\n", "url": "http://gitea.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5", "author": { "name": "Gordon the Gopher", "email": "gordon@golang.org", "username": "gordon" }, "added": ["CHANGELOG.md"], "removed": [], "modified": ["app/controller/application.rb"] } ], "repository": { "id": 1, "name": "hello-world", "full_name": "gordon/hello-world", "html_url": "http://gitea.golang.org/gordon/hello-world", "ssh_url": "git@gitea.golang.org:gordon/hello-world.git", "clone_url": "http://gitea.golang.org/gordon/hello-world.git", "description": "", "website": "", "watchers": 1, "owner": { "name": "gordon", "email": "gordon@golang.org", "login": "gordon", "username": "gordon" }, "private": true, "permissions": { "admin": true, "push": true, "pull": true } }, "pusher": { "name": "gordon", "email": "gordon@golang.org", "username": "gordon", "login": "gordon" }, "sender": { "login": "gordon", "id": 1, "username": "gordon", "email": "gordon@golang.org", "avatar_url": "http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" } } ================================================ FILE: server/forge/gitea/fixtures/HookPushBranch.json ================================================ { "ref": "refs/heads/fdsafdsa", "before": "0000000000000000000000000000000000000000", "after": "28c3613ae62640216bea5e7dc71aa65356e4298b", "compare_url": "https://codeberg.org/meisam/woodpecktester/compare/main...28c3613ae62640216bea5e7dc71aa65356e4298b", "commits": [], "head_commit": { "id": "28c3613ae62640216bea5e7dc71aa65356e4298b", "message": "Delete '.woodpecker/.check.yml'\n", "url": "https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b", "author": { "name": "meisam", "email": "meisam@noreply.codeberg.org", "username": "meisam" }, "committer": { "name": "meisam", "email": "meisam@noreply.codeberg.org", "username": "meisam" }, "verification": null, "timestamp": "2022-07-12T21:09:27+02:00", "added": [], "removed": [".woodpecker/.check.yml"], "modified": [] }, "repository": { "id": 50820, "owner": { "id": 14844, "login": "meisam", "full_name": "", "email": "meisam@noreply.codeberg.org", "avatar_url": "https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2020-10-08T11:19:12+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "Materials engineer, physics enthusiast, large collection of the bad programming habits, always happy to fix the old ones and make new mistakes!", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "meisam", "permissions": { "admin": true, "push": true, "pull": true } }, "name": "woodpecktester", "full_name": "meisam/woodpecktester", "description": "Just for testing the Woodpecker CI and reporting bugs", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 367, "language": "", "languages_url": "https://codeberg.org/api/v1/repos/meisam/woodpecktester/languages", "html_url": "https://codeberg.org/meisam/woodpecktester", "ssh_url": "git@codeberg.org:meisam/woodpecktester.git", "clone_url": "https://codeberg.org/meisam/woodpecktester.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 0, "watchers_count": 1, "open_issues_count": 0, "open_pr_counter": 0, "release_counter": 0, "default_branch": "main", "archived": false, "created_at": "2022-07-04T00:34:39+02:00", "updated_at": "2022-07-24T20:31:29+02:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": true, "has_pull_requests": true, "has_projects": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "default_merge_style": "merge", "avatar_url": "", "internal": false, "mirror_interval": "", "mirror_updated": "0001-01-01T00:00:00Z", "repo_transfer": null }, "pusher": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "visibility": "public", "followers_count": 22, "following_count": 16, "starred_repos_count": 55, "username": "6543" }, "sender": { "id": 2628, "login": "6543", "full_name": "", "email": "6543@obermui.de", "avatar_url": "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2019-10-12T05:05:49+02:00", "restricted": false, "active": false, "prohibit_login": false, "visibility": "public", "followers_count": 22, "following_count": 16, "starred_repos_count": 55, "username": "6543" } } ================================================ FILE: server/forge/gitea/fixtures/HookPushMulti.json ================================================ { "ref": "refs/heads/main", "before": "6efcf5b7c98f3e7a491675164b7a2e7acac27941", "after": "29be01c073851cf0db0c6a466e396b725a670453", "compare_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453", "commits": [ { "id": "29be01c073851cf0db0c6a466e396b725a670453", "message": "add some text\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:18:07+01:00", "added": [], "removed": [], "modified": ["aaa"] }, { "id": "29cd95250404bd007c13b03eabe521196bab98a5", "message": "rm a a file\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29cd95250404bd007c13b03eabe521196bab98a5", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:17:49+01:00", "added": [], "removed": ["aa"], "modified": [] }, { "id": "93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91", "message": "add some a files\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/93787b87b3134d0d62c7a24c1ea5b1b6fd17ca91", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:17:33+01:00", "added": ["aa", "aaa"], "removed": [], "modified": [] } ], "total_commits": 3, "head_commit": { "id": "29be01c073851cf0db0c6a466e396b725a670453", "message": "add some text\n", "url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets/commit/29be01c073851cf0db0c6a466e396b725a670453", "author": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "test-user" }, "verification": null, "timestamp": "2024-02-22T00:18:07+01:00", "added": [], "removed": [], "modified": ["aaa"] }, "repository": { "id": 6, "owner": { "id": 2, "login": "Test-CI", "login_name": "", "full_name": "", "email": "", "avatar_url": "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:48+02:00", "restricted": false, "active": false, "prohibit_login": false, "location": "", "website": "", "description": "", "visibility": "public", "followers_count": 0, "following_count": 0, "starred_repos_count": 0, "username": "Test-CI" }, "name": "multi-line-secrets", "full_name": "Test-CI/multi-line-secrets", "description": "", "empty": false, "private": false, "fork": false, "template": false, "parent": null, "mirror": false, "size": 35, "language": "", "languages_url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets/languages", "html_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets", "url": "http://127.0.0.1:3000/api/v1/repos/Test-CI/multi-line-secrets", "link": "", "ssh_url": "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", "clone_url": "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", "original_url": "", "website": "", "watchers_count": 2, "open_issues_count": 1, "default_branch": "main", "archived": false, "created_at": "2023-10-31T19:53:15+01:00", "updated_at": "2023-11-02T06:16:34+01:00", "archived_at": "1970-01-01T01:00:00+01:00", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "avatar_url": "", "object_format_name": "" }, "pusher": { "id": 1, "login": "test-user", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "prohibit_login": false, "description": "", "visibility": "public", "username": "test-user" }, "sender": { "id": 1, "login": "test-user", "login_name": "", "full_name": "", "email": "test@noreply.localhost", "avatar_url": "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2023-07-31T19:13:05+02:00", "prohibit_login": false, "description": "", "visibility": "public", "username": "test-user" } } ================================================ FILE: server/forge/gitea/fixtures/HookRelease.json ================================================ { "action": "published", "release": { "id": 48, "tag_name": "0.0.5", "target_commitish": "main", "name": "Version 0.0.5", "body": "", "url": "https://git.xxx/api/v1/repos/anbraten/demo/releases/48", "html_url": "https://git.xxx/anbraten/demo/releases/tag/0.0.5", "tarball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.tar.gz", "zipball_url": "https://git.xxx/anbraten/demo/archive/0.0.5.zip", "draft": false, "prerelease": false, "created_at": "2022-02-09T20:23:05Z", "published_at": "2022-02-09T20:23:05Z", "author": { "id": 1, "login": "anbraten", "full_name": "Anton Bracke", "email": "anbraten@noreply.xxx", "avatar_url": "https://git.xxx/user/avatar/anbraten/-1", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2018-03-21T10:04:48Z", "restricted": false, "active": false, "prohibit_login": false, "location": "world", "website": "https://xxx", "description": "", "visibility": "public", "followers_count": 1, "following_count": 1, "starred_repos_count": 1, "username": "anbraten" }, "assets": [] }, "repository": { "id": 77, "owner": { "id": 1, "login": "anbraten", "full_name": "Anton Bracke", "email": "anbraten@noreply.xxx", "avatar_url": "https://git.xxx/user/avatar/anbraten/-1", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2018-03-21T10:04:48Z", "restricted": false, "active": false, "prohibit_login": false, "location": "world", "website": "https://xxx", "description": "", "visibility": "public", "followers_count": 1, "following_count": 1, "starred_repos_count": 1, "username": "anbraten" }, "name": "demo", "full_name": "anbraten/demo", "description": "", "empty": false, "private": true, "fork": false, "template": false, "parent": null, "mirror": false, "size": 59, "html_url": "https://git.xxx/anbraten/demo", "ssh_url": "ssh://git@git.xxx:22/anbraten/demo.git", "clone_url": "https://git.xxx/anbraten/demo.git", "original_url": "", "website": "", "stars_count": 0, "forks_count": 1, "watchers_count": 1, "open_issues_count": 2, "open_pr_counter": 2, "release_counter": 4, "default_branch": "main", "archived": false, "created_at": "2021-08-30T20:54:13Z", "updated_at": "2022-01-09T01:29:23Z", "permissions": { "admin": true, "push": true, "pull": true }, "has_issues": true, "internal_tracker": { "enable_time_tracker": true, "allow_only_contributors_to_track_time": true, "enable_issue_dependencies": true }, "has_wiki": false, "has_pull_requests": true, "has_projects": true, "ignore_whitespace_conflicts": false, "allow_merge_commits": true, "allow_rebase": true, "allow_rebase_explicit": true, "allow_squash_merge": true, "default_merge_style": "squash", "avatar_url": "", "internal": false, "mirror_interval": "" }, "sender": { "id": 1, "login": "anbraten", "full_name": "Anbraten", "email": "anbraten@noreply.xxx", "avatar_url": "https://git.xxx/user/avatar/anbraten/-1", "language": "", "is_admin": false, "last_login": "0001-01-01T00:00:00Z", "created": "2018-03-21T10:04:48Z", "restricted": false, "active": false, "prohibit_login": false, "location": "World", "website": "https://xxx", "description": "", "visibility": "public", "followers_count": 1, "following_count": 1, "starred_repos_count": 1, "username": "anbraten" } } ================================================ FILE: server/forge/gitea/fixtures/HookTag.json ================================================ { "sha": "ef98532add3b2feb7a137426bba1248724367df5", "secret": "l26Un7G7HXogLAvsyf2hOA4EMARSTsR3", "ref": "v1.0.0", "ref_type": "tag", "repository": { "id": 12, "owner": { "id": 4, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" }, "name": "hello-world", "full_name": "gordon/hello-world", "description": "a hello world example", "private": true, "fork": false, "html_url": "http://gitea.golang.org/gordon/hello-world", "ssh_url": "git@gitea.golang.org:gordon/hello-world.git", "clone_url": "http://gitea.golang.org/gordon/hello-world.git", "default_branch": "main", "created_at": "2015-10-22T19:32:44Z", "updated_at": "2016-11-24T13:37:16Z", "permissions": { "admin": true, "push": true, "pull": true } }, "sender": { "id": 1, "username": "gordon", "login": "gordon", "full_name": "Gordon the Gopher", "email": "gordon@golang.org", "avatar_url": "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87" } } ================================================ FILE: server/forge/gitea/fixtures/handler.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( "net/http" "github.com/gin-gonic/gin" ) // Handler returns an http.Handler that is capable of handling a variety of mock // Gitea requests and returning mock responses. func Handler() http.Handler { gin.SetMode(gin.TestMode) e := gin.New() e.GET("/api/v1/repos/:owner/:name", getRepo) e.GET("/api/v1/repositories/:id", getRepoByID) e.GET("/api/v1/repos/:owner/:name/raw/:file", getRepoFile) e.POST("/api/v1/repos/:owner/:name/hooks", createRepoHook) e.GET("/api/v1/repos/:owner/:name/hooks", listRepoHooks) e.DELETE("/api/v1/repos/:owner/:name/hooks/:id", deleteRepoHook) e.POST("/api/v1/repos/:owner/:name/statuses/:commit", createRepoCommitStatus) e.GET("/api/v1/repos/:owner/:name/pulls/:index/files", getPRFiles) e.GET("/api/v1/user/repos", getUserRepos) e.GET("/api/v1/version", getVersion) return e } func listRepoHooks(c *gin.Context) { page := c.Query("page") if page != "" && page != "1" { c.String(http.StatusOK, "[]") } else { c.String(http.StatusOK, listRepoHookPayloads) } } func getRepo(c *gin.Context) { switch c.Param("name") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoPayload) } } func getRepoByID(c *gin.Context) { switch c.Param("id") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoPayload) } } func createRepoCommitStatus(c *gin.Context) { if c.Param("commit") == "v1.0.0" || c.Param("commit") == "9ecad50" { c.String(http.StatusOK, repoPayload) } c.String(http.StatusNotFound, "") } func getRepoFile(c *gin.Context) { file := c.Param("file") ref := c.Query("ref") if file == "file_not_found" { c.String(http.StatusNotFound, "") } if ref == "v1.0.0" || ref == "9ecad50" { c.String(http.StatusOK, repoFilePayload) } c.String(http.StatusNotFound, "") } func createRepoHook(c *gin.Context) { in := struct { Type string `json:"type"` Conf struct { Type string `json:"content_type"` URL string `json:"url"` } `json:"config"` }{} _ = c.BindJSON(&in) if in.Type != "gitea" || in.Conf.Type != "json" || in.Conf.URL != "http://localhost" { c.String(http.StatusInternalServerError, "") return } c.String(http.StatusOK, "{}") } func deleteRepoHook(c *gin.Context) { c.String(http.StatusOK, "{}") } func getUserRepos(c *gin.Context) { switch c.Request.Header.Get("Authorization") { case "token repos_not_found": c.String(http.StatusNotFound, "") default: page := c.Query("page") if page != "" && page != "1" { c.String(http.StatusOK, "[]") } else { c.String(http.StatusOK, userRepoPayload) } } } func getVersion(c *gin.Context) { c.JSON(http.StatusOK, map[string]any{"version": "1.18.0"}) } func getPRFiles(c *gin.Context) { page := c.Query("page") if page == "1" { c.String(http.StatusOK, prFilesPayload) } else { c.String(http.StatusOK, "[]") } } const listRepoHookPayloads = ` [ { "id": 1, "type": "gitea", "config": { "content_type": "json", "url": "http:\/\/localhost\/hook?access_token=1234567890" } } ] ` const repoPayload = ` { "id": 5, "owner": { "login": "test_name", "email": "octocat@github.com", "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/8c58a0be77ee441bb8f8595b7f1b4e87" }, "full_name": "test_name\/repo_name", "private": true, "html_url": "http:\/\/localhost\/test_name\/repo_name", "clone_url": "http:\/\/localhost\/test_name\/repo_name.git", "permissions": { "admin": true, "push": true, "pull": true } } ` const repoFilePayload = `{ platform: linux/amd64 }` const userRepoPayload = ` [ { "id": 5, "owner": { "login": "test_name", "email": "octocat@github.com", "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/8c58a0be77ee441bb8f8595b7f1b4e87" }, "full_name": "test_name\/repo_name", "private": true, "html_url": "http:\/\/localhost\/test_name\/repo_name", "clone_url": "http:\/\/localhost\/test_name\/repo_name.git", "permissions": { "admin": true, "push": true, "pull": true } } ] ` const prFilesPayload = ` [ { "filename": "README.md", "status": "changed", "additions": 2, "deletions": 0, "changes": 2, "html_url": "http://localhost/username/repo/src/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md", "contents_url": "http://localhost:3000/api/v1/repos/username/repo/contents/README.md?ref=e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd", "raw_url": "http://localhost/username/repo/raw/commit/e79e4b0e8d9dd6f72b70e776c3317db7c19ca0fd/README.md" } ] ` ================================================ FILE: server/forge/gitea/fixtures/hooks.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import _ "embed" // HookPush is a sample Gitea push hook. // //go:embed HookPush.json var HookPush string // HookPushMulti push multible commits to a branch. // //go:embed HookPushMulti.json var HookPushMulti string // HookPushBranch is a sample Gitea push hook where a new branch was created from an existing commit. // //go:embed HookPushBranch.json var HookPushBranch string // HookTag is a sample Gitea tag hook. // //go:embed HookTag.json var HookTag string // HookPullRequest is a sample pull_request webhook payload. // //go:embed HookPullRequest.json var HookPullRequest string //go:embed HookPullRequestUpdated.json var HookPullRequestUpdated string //go:embed HookPullRequestMerged.json var HookPullRequestMerged string //go:embed HookPullRequestClosed.json var HookPullRequestClosed string //go:embed HookPullRequestChangeTitle.json var HookPullRequestChangeTitle string //go:embed HookPullRequestChangeBody.json var HookPullRequestChangeBody string //go:embed HookPullRequestAddReviewRequest.json var HookPullRequestAddReviewRequest string //go:embed HookPullRequestReviewAck.json var HookPullRequestReviewAck string //go:embed HookPullRequestReviewDeny.json var HookPullRequestReviewDeny string //go:embed HookPullRequestReviewComment.json var HookPullRequestReviewComment string //go:embed HookPullRequestAddLabel.json var HookPullRequestAddLabel string //go:embed HookPullRequestChangeLabel.json var HookPullRequestChangeLabel string //go:embed HookPullRequestRemoveLabel.json var HookPullRequestRemoveLabel string //go:embed HookPullRequestAddMile.json var HookPullRequestAddMile string //go:embed HookPullRequestChangeMile.json var HookPullRequestChangeMile string //go:embed HookPullRequestRemoveMile.json var HookPullRequestRemoveMile string //go:embed HookPullRequestAssigneesAdded.json var HookPullRequestAssigneesAdded string //go:embed HookPullRequestAssigneesRemoved.json var HookPullRequestAssigneesRemoved string //go:embed HookRelease.json var HookRelease string //go:embed HookPullRequestReopened.json var HookPullRequestReopened string ================================================ FILE: server/forge/gitea/gitea.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "context" "crypto/tls" "errors" "fmt" "net/http" "strconv" "strings" "time" "code.gitea.io/sdk/gitea" "github.com/rs/zerolog/log" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" shared_utils "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( authorizeTokenURL = "%s/login/oauth/authorize" accessTokenURL = "%s/login/oauth/access_token" defaultPageSize = 50 giteaDevVersion = "v1.21.0" ) type Gitea struct { id int64 url string oAuthClientID string oAuthClientSecret string oAuthHost string skipVerify bool pageSize int } // Opts defines configuration options. type Opts struct { URL string // Gitea server url. OAuthClientID string // OAuth2 Client ID OAuthClientSecret string // OAuth2 Client Secret OAuthHost string // OAuth2 Host SkipVerify bool // Skip ssl verification. } // New returns a Forge implementation that integrates with Gitea, // an open source Git service written in Go. See https://gitea.io/ func New(id int64, opts Opts) (forge.Forge, error) { return &Gitea{ id: id, url: opts.URL, oAuthClientID: opts.OAuthClientID, oAuthClientSecret: opts.OAuthClientSecret, oAuthHost: opts.OAuthHost, skipVerify: opts.SkipVerify, }, nil } // Name returns the string name of this driver. func (c *Gitea) Name() string { return "gitea" } // URL returns the root url of a configured forge. func (c *Gitea) URL() string { return c.url } func (c *Gitea) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) { publicOAuthURL := c.oAuthHost if publicOAuthURL == "" { publicOAuthURL = c.url } return &oauth2.Config{ ClientID: c.oAuthClientID, ClientSecret: c.oAuthClientSecret, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf(authorizeTokenURL, publicOAuthURL), TokenURL: fmt.Sprintf(accessTokenURL, c.url), }, RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), }, context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: c.skipVerify}, Proxy: http.ProxyFromEnvironment, }}) } // Login authenticates an account with Gitea using basic authentication. The // Gitea account details are returned when the user is successfully authenticated. func (c *Gitea) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { config, oauth2Ctx := c.oauth2Config(ctx) redirectURL := config.AuthCodeURL(req.State) // check the OAuth code if len(req.Code) == 0 { return nil, redirectURL, nil } token, err := config.Exchange(oauth2Ctx, req.Code) if err != nil { return nil, redirectURL, fmt.Errorf("oauth2 config exchange failed: %w", err) } client, err := c.newClientToken(ctx, token.AccessToken) if err != nil { return nil, redirectURL, fmt.Errorf("client creation with new access token failed: %w", err) } account, _, err := client.GetMyUserInfo() if err != nil { return nil, redirectURL, fmt.Errorf("fetching user info failed: %w", err) } return &model.User{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry.UTC().Unix(), Login: account.UserName, Email: account.Email, ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(account.ID)), Avatar: expandAvatar(c.url, account.AvatarURL), }, redirectURL, nil } // Refresh refreshes the Gitea oauth2 access token. If the token is // refreshed, the user is updated and a true value is returned. func (c *Gitea) Refresh(ctx context.Context, user *model.User) (bool, error) { config, oauth2Ctx := c.oauth2Config(ctx) config.RedirectURL = "" source := config.TokenSource(oauth2Ctx, &oauth2.Token{ AccessToken: user.AccessToken, RefreshToken: user.RefreshToken, Expiry: time.Unix(user.Expiry, 0), }) token, err := source.Token() if err != nil || len(token.AccessToken) == 0 { return false, err } user.AccessToken = token.AccessToken user.RefreshToken = token.RefreshToken user.Expiry = token.Expiry.UTC().Unix() return true, nil } // Teams is supported by the Gitea driver. func (c *Gitea) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { // we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667) if p.Page != 1 { return nil, nil } client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } return shared_utils.Paginate(func(page int) ([]*model.Team, error) { orgs, _, err := client.ListMyOrgs( gitea.ListOrgsOptions{ ListOptions: gitea.ListOptions{ Page: page, PageSize: c.perPage(ctx), }, }, ) teams := make([]*model.Team, 0, len(orgs)) for _, org := range orgs { teams = append(teams, toTeam(org, c.url)) } return teams, err }, -1) } // TeamPerm is not supported by the Gitea driver. func (c *Gitea) TeamPerm(_ *model.User, _ string) (*model.Perm, error) { return nil, nil } // Repo returns the Gitea repository. func (c *Gitea) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } if remoteID.IsValid() { intID, err := strconv.ParseInt(string(remoteID), 10, 64) if err != nil { return nil, err } repo, resp, err := client.GetRepoByID(intID) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return nil, err } return toRepo(repo), nil } repo, resp, err := client.GetRepo(owner, name) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return nil, err } return toRepo(repo), nil } // Repos returns a list of all repositories for the Gitea account, including // organization repositories. func (c *Gitea) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { // we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667) if p.Page != 1 { return nil, nil } client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } repos, err := shared_utils.Paginate(func(page int) ([]*gitea.Repository, error) { repos, _, err := client.ListMyRepos( gitea.ListReposOptions{ ListOptions: gitea.ListOptions{ Page: page, PageSize: c.perPage(ctx), }, }, ) return repos, err }, -1) result := make([]*model.Repo, 0, len(repos)) for _, repo := range repos { if repo.Archived { continue } result = append(result, toRepo(repo)) } return result, err } // File fetches the file from the Gitea repository and returns its contents. func (c *Gitea) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } cfg, resp, err := client.GetFile(r.Owner, r.Name, b.Commit, f) if err != nil && resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}}) } return cfg, err } func (c *Gitea) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) { var configs []*forge_types.FileMeta client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } // List files in repository contents, resp, err := client.ListContents(r.Owner, r.Name, b.Commit, f) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}}) } return nil, err } for _, e := range contents { if e.Type == "file" { data, err := c.File(ctx, u, r, b, e.Path) if err != nil { return nil, fmt.Errorf("multi-pipeline cannot get %s: %w", e.Path, err) } configs = append(configs, &forge_types.FileMeta{ Name: e.Path, Data: data, }) } } return configs, nil } // Status is supported by the Gitea driver. func (c *Gitea) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { client, err := c.newClientToken(ctx, user.AccessToken) if err != nil { return err } _, _, err = client.CreateStatus( repo.Owner, repo.Name, pipeline.Commit, gitea.CreateStatusOption{ State: getStatus(workflow.State), TargetURL: common.GetPipelineStatusURL(repo, pipeline, workflow), Description: common.GetPipelineStatusDescription(workflow.State), Context: common.GetPipelineStatusContext(repo, pipeline, workflow), }, ) return err } // Netrc returns a netrc file capable of authenticating Gitea requests and // cloning Gitea repositories. The netrc will use the global machine account // when configured. func (c *Gitea) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { login := "" token := "" if u != nil { login = u.Login token = u.AccessToken } host, err := common.ExtractHostFromCloneURL(r.Clone) if err != nil { return nil, err } return &model.Netrc{ Login: login, Password: token, Machine: host, Type: model.ForgeTypeGitea, }, nil } // Activate activates the repository by registering post-commit hooks with // the Gitea repository. func (c *Gitea) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { config := map[string]string{ "url": link, "secret": r.Hash, "content_type": "json", } hook := gitea.CreateHookOption{ Type: gitea.HookTypeGitea, Config: config, Events: []string{"push", "create", "pull_request", "release"}, Active: true, } client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return err } _, response, err := client.CreateRepoHook(r.Owner, r.Name, hook) if err != nil { if response != nil { if response.StatusCode == http.StatusNotFound { return fmt.Errorf("could not find repository") } if response.StatusCode == http.StatusOK { return fmt.Errorf("could not find repository, repository was probably renamed") } } return err } return nil } // Deactivate deactivates the repository be removing repository push hooks from // the Gitea repository. func (c *Gitea) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return err } // make sure a repo rename does not trick us forgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name) if err != nil { return err } hooks, err := shared_utils.Paginate(func(page int) ([]*gitea.Hook, error) { hooks, _, err := client.ListRepoHooks(forgeRepo.Owner, forgeRepo.Name, gitea.ListHooksOptions{ ListOptions: gitea.ListOptions{ Page: page, PageSize: c.perPage(ctx), }, }) return hooks, err }, -1) if err != nil { return err } hook := matchingHooks(hooks, link) if hook != nil { _, err := client.DeleteRepoHook(forgeRepo.Owner, forgeRepo.Name, hook.ID) return err } return nil } // Branches returns the names of all branches for the named repository. func (c *Gitea) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { token := common.UserToken(ctx, r, u) client, err := c.newClientToken(ctx, token) if err != nil { return nil, err } branches, _, err := client.ListRepoBranches(r.Owner, r.Name, gitea.ListRepoBranchesOptions{ListOptions: gitea.ListOptions{Page: p.Page, PageSize: p.PerPage}}) if err != nil { return nil, err } result := make([]string, len(branches)) for i := range branches { result[i] = branches[i].Name } return result, err } // BranchHead returns the sha of the head (latest commit) of the specified branch. func (c *Gitea) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { token := common.UserToken(ctx, r, u) client, err := c.newClientToken(ctx, token) if err != nil { return nil, err } b, _, err := client.GetRepoBranch(r.Owner, r.Name, branch) if err != nil { return nil, err } return &model.Commit{ SHA: b.Commit.ID, ForgeURL: b.Commit.URL, }, nil } func (c *Gitea) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { token := common.UserToken(ctx, r, u) client, err := c.newClientToken(ctx, token) if err != nil { return nil, err } pullRequests, resp, err := client.ListRepoPullRequests(r.Owner, r.Name, gitea.ListPullRequestsOptions{ ListOptions: gitea.ListOptions{Page: p.Page, PageSize: p.PerPage}, State: gitea.StateOpen, }) if err != nil { // Repositories without commits return empty list with status code 404 if pullRequests != nil && resp != nil && resp.StatusCode == http.StatusNotFound { err = nil } else { return nil, err } } result := make([]*model.PullRequest, len(pullRequests)) for i := range pullRequests { result[i] = &model.PullRequest{ Index: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].Index))), Title: pullRequests[i].Title, } } return result, err } // Hook parses the incoming Gitea hook and returns the Repository and Pipeline // details. If the hook is unsupported nil values are returned. func (c *Gitea) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { repo, pipeline, err := parseHook(r) if err != nil { return nil, nil, err } if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" { tagName := strings.Split(pipeline.Ref, "/")[2] sha, err := c.getTagCommitSHA(ctx, repo, tagName) if err != nil { return nil, nil, err } pipeline.Commit = sha } if pipeline != nil && pipeline.IsPullRequest() && len(pipeline.ChangedFiles) == 0 { index, err := strconv.ParseInt(strings.Split(pipeline.Ref, "/")[2], 10, 64) if err != nil { return nil, nil, err } pipeline.ChangedFiles, err = c.getChangedFilesForPR(ctx, repo, index) if err != nil { log.Error().Err(err).Msgf("could not get changed files for PR %s#%d", repo.FullName, index) } } return repo, pipeline, nil } // OrgMembership returns if user is member of organization and if user // is admin/owner in this organization. func (c *Gitea) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } member, _, err := client.CheckOrgMembership(owner, u.Login) if err != nil { return nil, err } if !member { return &model.OrgPerm{}, nil } perm, _, err := client.GetOrgPermissions(owner, u.Login) if err != nil { return &model.OrgPerm{Member: member}, err } return &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil } func (c *Gitea) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) { client, err := c.newClientToken(ctx, u.AccessToken) if err != nil { return nil, err } org, _, orgErr := client.GetOrg(owner) if orgErr == nil && org != nil { return &model.Org{ Name: org.Name, Private: gitea.VisibleType(org.Visibility) != gitea.VisibleTypePublic, }, nil } user, _, err := client.GetUserInfo(owner) if err != nil { if orgErr != nil { err = errors.Join(orgErr, err) } return nil, err } return &model.Org{ Name: user.UserName, IsUser: true, Private: user.Visibility != gitea.VisibleTypePublic, }, nil } // newClientToken returns the Gitea client with Token. func (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client, error) { httpClient := &http.Client{} if c.skipVerify { httpClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } wrappedClient := httputil.WrapClient(httpClient, "forge-gitea") client, err := gitea.NewClient(c.url, gitea.SetToken(token), gitea.SetHTTPClient(wrappedClient), gitea.SetContext(ctx)) if err != nil && (errors.Is(err, &gitea.ErrUnknownVersion{}) || strings.Contains(err.Error(), "Malformed version")) { // we guess it's a dev gitea version log.Error().Err(err).Msgf("could not detect gitea version, assume dev version %s", giteaDevVersion) client, err = gitea.NewClient(c.url, gitea.SetGiteaVersion(giteaDevVersion), gitea.SetToken(token), gitea.SetHTTPClient(wrappedClient), gitea.SetContext(ctx)) } return client, err } // getStatus is a helper function that converts a Woodpecker // status to a Gitea status. func getStatus(status model.StatusValue) gitea.StatusState { switch status { case model.StatusPending, model.StatusBlocked: return gitea.StatusPending case model.StatusRunning: return gitea.StatusPending case model.StatusSuccess: return gitea.StatusSuccess case model.StatusFailure: return gitea.StatusFailure case model.StatusKilled: return gitea.StatusFailure case model.StatusDeclined: return gitea.StatusWarning case model.StatusError: return gitea.StatusError default: return gitea.StatusFailure } } func (c *Gitea) getChangedFilesForPR(ctx context.Context, repo *model.Repo, index int64) ([]string, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return []string{}, nil } repo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName) if err != nil { return nil, err } user, err := _store.GetUser(repo.UserID) if err != nil { return nil, err } forge.Refresh(ctx, c, _store, user) client, err := c.newClientToken(ctx, user.AccessToken) if err != nil { return nil, err } return shared_utils.Paginate(func(page int) ([]string, error) { giteaFiles, _, err := client.ListPullRequestFiles(repo.Owner, repo.Name, index, gitea.ListPullRequestFilesOptions{ListOptions: gitea.ListOptions{Page: page}}) if err != nil { return nil, err } var files []string for _, file := range giteaFiles { files = append(files, file.Filename) } return files, nil }, -1) } func (c *Gitea) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return "", nil } repo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName) if err != nil { return "", err } user, err := _store.GetUser(repo.UserID) if err != nil { return "", err } forge.Refresh(ctx, c, _store, user) client, err := c.newClientToken(ctx, user.AccessToken) if err != nil { return "", err } tag, _, err := client.GetTag(repo.Owner, repo.Name, tagName) if err != nil { return "", err } return tag.Commit.SHA, nil } func (c *Gitea) perPage(ctx context.Context) int { if c.pageSize == 0 { client, err := c.newClientToken(ctx, "") if err != nil { return defaultPageSize } api, _, err := client.GetGlobalAPISettings() if err != nil { return defaultPageSize } c.pageSize = api.MaxResponseItems } return c.pageSize } ================================================ FILE: server/forge/gitea/gitea_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "bytes" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestNew(t *testing.T) { forge, _ := New(1, Opts{ URL: "http://localhost:8080", SkipVerify: true, }) f, _ := forge.(*Gitea) assert.Equal(t, "http://localhost:8080", f.url) assert.True(t, f.skipVerify) } func Test_gitea(t *testing.T) { gin.SetMode(gin.TestMode) s := httptest.NewServer(fixtures.Handler()) defer s.Close() c, _ := New(1, Opts{ URL: s.URL, SkipVerify: true, }) mockStore := store_mocks.NewMockStore(t) ctx := store.InjectToContext(t.Context(), mockStore) t.Run("netrc with user token", func(t *testing.T) { forge, _ := New(1, Opts{}) netrc, _ := forge.Netrc(fakeUser, fakeRepo) assert.Equal(t, "gitea.com", netrc.Machine) assert.Equal(t, fakeUser.Login, netrc.Login) assert.Equal(t, fakeUser.AccessToken, netrc.Password) assert.Equal(t, model.ForgeTypeGitea, netrc.Type) }) t.Run("netrc with machine account", func(t *testing.T) { forge, _ := New(1, Opts{}) netrc, _ := forge.Netrc(nil, fakeRepo) assert.Equal(t, "gitea.com", netrc.Machine) assert.Empty(t, netrc.Login) assert.Empty(t, netrc.Password) }) t.Run("repository details", func(t *testing.T) { repo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name) assert.NoError(t, err) assert.Equal(t, fakeRepo.Owner, repo.Owner) assert.Equal(t, fakeRepo.Name, repo.Name) assert.Equal(t, fakeRepo.Owner+"/"+fakeRepo.Name, repo.FullName) assert.True(t, repo.IsSCMPrivate) assert.Equal(t, "http://localhost/test_name/repo_name.git", repo.Clone) assert.Equal(t, "http://localhost/test_name/repo_name", repo.ForgeURL) }) t.Run("repo not found", func(t *testing.T) { _, err := c.Repo(ctx, fakeUser, "0", fakeRepoNotFound.Owner, fakeRepoNotFound.Name) assert.Error(t, err) }) t.Run("repository list", func(t *testing.T) { repos, err := c.Repos(ctx, fakeUser, &model.ListOptions{Page: 1, PerPage: 10}) assert.NoError(t, err) assert.Equal(t, fakeRepo.ForgeRemoteID, repos[0].ForgeRemoteID) assert.Equal(t, fakeRepo.Owner, repos[0].Owner) assert.Equal(t, fakeRepo.Name, repos[0].Name) assert.Equal(t, fakeRepo.Owner+"/"+fakeRepo.Name, repos[0].FullName) }) t.Run("not found error", func(t *testing.T) { _, err := c.Repos(ctx, fakeUserNoRepos, &model.ListOptions{Page: 1, PerPage: 10}) assert.Error(t, err) }) t.Run("register repository", func(t *testing.T) { err := c.Activate(ctx, fakeUser, fakeRepo, "http://localhost") assert.NoError(t, err) }) t.Run("remove hooks", func(t *testing.T) { err := c.Deactivate(ctx, fakeUser, fakeRepo, "http://localhost") assert.NoError(t, err) }) t.Run("repository file", func(t *testing.T) { raw, err := c.File(ctx, fakeUser, fakeRepo, fakePipeline, ".woodpecker.yml") assert.NoError(t, err) assert.Equal(t, "{ platform: linux/amd64 }", string(raw)) }) t.Run("pipeline status", func(t *testing.T) { err := c.Status(ctx, fakeUser, fakeRepo, fakePipeline, fakeWorkflow) assert.NoError(t, err) }) t.Run("PR hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, hookPullRequest) mockStore.On("GetRepoNameFallback", mock.Anything, mock.Anything, mock.Anything).Return(fakeRepo, nil) mockStore.On("GetUser", mock.Anything).Return(fakeUser, nil) r, b, err := c.Hook(ctx, req) assert.NotNil(t, r) assert.NotNil(t, b) assert.NoError(t, err) assert.Equal(t, model.EventPull, b.Event) assert.Equal(t, []string{"README.md"}, b.ChangedFiles) }) } var ( fakeUser = &model.User{ Login: "someuser", AccessToken: "cfcd2084", } fakeUserNoRepos = &model.User{ Login: "someuser", AccessToken: "repos_not_found", } fakeRepo = &model.Repo{ Clone: "http://gitea.com/test_name/repo_name.git", ForgeRemoteID: "5", Owner: "test_name", Name: "repo_name", FullName: "test_name/repo_name", } fakeRepoNotFound = &model.Repo{ Owner: "test_name", Name: "repo_not_found", FullName: "test_name/repo_not_found", } fakePipeline = &model.Pipeline{ Commit: "9ecad50", } fakeWorkflow = &model.Workflow{ Name: "test", State: model.StatusSuccess, } ) ================================================ FILE: server/forge/gitea/helper.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "encoding/json" "fmt" "io" "net/url" "strings" "time" "code.gitea.io/sdk/gitea" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) // toRepo converts a Gitea repository to a Woodpecker repository. func toRepo(from *gitea.Repository) *model.Repo { name := strings.Split(from.FullName, "/")[1] avatar := expandAvatar( from.HTMLURL, from.Owner.AvatarURL, ) return &model.Repo{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.ID)), Name: name, Owner: from.Owner.UserName, FullName: from.FullName, Avatar: avatar, ForgeURL: from.HTMLURL, IsSCMPrivate: from.Private || from.Owner.Visibility != gitea.VisibleTypePublic, Clone: from.CloneURL, CloneSSH: from.SSHURL, Branch: from.DefaultBranch, Perm: toPerm(from.Permissions), PREnabled: from.HasPullRequests, } } // toPerm converts a Gitea permission to a Woodpecker permission. func toPerm(from *gitea.Permission) *model.Perm { return &model.Perm{ Pull: from.Pull, Push: from.Push, Admin: from.Admin, } } // toTeam converts a Gitea team to a Woodpecker team. func toTeam(from *gitea.Organization, link string) *model.Team { return &model.Team{ Login: from.Name, Avatar: expandAvatar(link, from.AvatarURL), } } // pipelineFromPush extracts the Pipeline data from a Gitea push hook. func pipelineFromPush(hook *pushHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.Sender.AvatarURL), ) var message string link := hook.Compare if len(hook.Commits) > 0 { message = hook.Commits[0].Message if len(hook.Commits) == 1 { link = hook.Commits[0].URL } } else { message = hook.HeadCommit.Message link = hook.HeadCommit.URL } return &model.Pipeline{ Event: model.EventPush, Commit: hook.After, Ref: hook.Ref, ForgeURL: link, Branch: strings.TrimPrefix(hook.Ref, "refs/heads/"), Message: message, Avatar: avatar, Author: hook.Sender.UserName, Email: hook.Sender.Email, Timestamp: time.Now().UTC().Unix(), Sender: hook.Sender.UserName, ChangedFiles: getChangedFilesFromPushHook(hook), } } func getChangedFilesFromPushHook(hook *pushHook) []string { // assume a capacity of 4 changed files per commit files := make([]string, 0, len(hook.Commits)*4) for _, c := range hook.Commits { files = append(files, c.Added...) files = append(files, c.Removed...) files = append(files, c.Modified...) } files = append(files, hook.HeadCommit.Added...) files = append(files, hook.HeadCommit.Removed...) files = append(files, hook.HeadCommit.Modified...) return utils.DeduplicateStrings(files) } // pipelineFromTag extracts the Pipeline data from a Gitea tag hook. func pipelineFromTag(hook *pushHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.Sender.AvatarURL), ) ref := strings.TrimPrefix(hook.Ref, "refs/tags/") return &model.Pipeline{ Event: model.EventTag, Commit: hook.Sha, Ref: fmt.Sprintf("refs/tags/%s", ref), ForgeURL: fmt.Sprintf("%s/src/tag/%s", hook.Repo.HTMLURL, ref), Message: fmt.Sprintf("created tag %s", ref), Avatar: avatar, Author: hook.Sender.UserName, Sender: hook.Sender.UserName, Email: hook.Sender.Email, Timestamp: time.Now().UTC().Unix(), } } // pipelineFromPullRequest extracts the Pipeline data from a Gitea pull_request hook. func pipelineFromPullRequest(hook *pullRequestHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.PullRequest.Poster.AvatarURL), ) event := model.EventPull switch hook.Action { case actionClose: event = model.EventPullClosed case actionEdited, actionLabelUpdate, actionLabelCleared, actionMilestoned, actionDeMilestoned, actionAssigned, actionUnAssigned: event = model.EventPullMetadata } pipeline := &model.Pipeline{ Event: event, Commit: hook.PullRequest.Head.Sha, ForgeURL: hook.PullRequest.HTMLURL, Ref: fmt.Sprintf("refs/pull/%d/head", hook.Number), Branch: hook.PullRequest.Base.Ref, Message: hook.PullRequest.Title, Author: hook.PullRequest.Poster.UserName, Avatar: avatar, Sender: hook.Sender.UserName, Email: hook.Sender.Email, Title: hook.PullRequest.Title, Refspec: fmt.Sprintf("%s:%s", hook.PullRequest.Head.Ref, hook.PullRequest.Base.Ref, ), PullRequestLabels: convertLabels(hook.PullRequest.Labels), PullRequestMilestone: convertMilestone(hook.PullRequest.Milestone), FromFork: hook.PullRequest.Head.RepoID != hook.PullRequest.Base.RepoID, } if pipeline.Event == model.EventPullMetadata { pipeline.EventReason = []string{hook.Action} } return pipeline } func convertMilestone(milestone *gitea.Milestone) string { if milestone == nil || milestone.ID == 0 { return "" } return milestone.Title } func pipelineFromRelease(hook *releaseHook) *model.Pipeline { avatar := expandAvatar( hook.Repo.HTMLURL, fixMalformedAvatar(hook.Sender.AvatarURL), ) return &model.Pipeline{ Event: model.EventRelease, Ref: fmt.Sprintf("refs/tags/%s", hook.Release.TagName), ForgeURL: hook.Release.HTMLURL, Branch: hook.Release.Target, Message: fmt.Sprintf("created release %s", hook.Release.Title), Avatar: avatar, Author: hook.Sender.UserName, Sender: hook.Sender.UserName, Email: hook.Sender.Email, IsPrerelease: hook.Release.IsPrerelease, } } // parsePush parses a push hook from a read closer. func parsePush(r io.Reader) (*pushHook, error) { push := new(pushHook) err := json.NewDecoder(r).Decode(push) return push, err } func parsePullRequest(r io.Reader) (*pullRequestHook, error) { pr := new(pullRequestHook) err := json.NewDecoder(r).Decode(pr) return pr, err } func parseRelease(r io.Reader) (*releaseHook, error) { pr := new(releaseHook) err := json.NewDecoder(r).Decode(pr) return pr, err } // fixMalformedAvatar fixes an avatar url if malformed (currently a known bug with gitea). func fixMalformedAvatar(url string) string { index := strings.Index(url, "///") if index != -1 { return url[index+1:] } index = strings.Index(url, "//avatars/") if index != -1 { return strings.ReplaceAll(url, "//avatars/", "/avatars/") } return url } // expandAvatar converts a relative avatar URL to the absolute url. func expandAvatar(repo, rawURL string) string { aURL, err := url.Parse(rawURL) if err != nil { return rawURL } if aURL.IsAbs() { // Url is already absolute return aURL.String() } // Resolve to base burl, err := url.Parse(repo) if err != nil { return rawURL } aURL = burl.ResolveReference(aURL) return aURL.String() } // matchingHooks return matching hooks. func matchingHooks(hooks []*gitea.Hook, rawURL string) *gitea.Hook { link, err := url.Parse(rawURL) if err != nil { return nil } for _, hook := range hooks { if val, ok := hook.Config["url"]; ok { hookURL, err := url.Parse(val) if err == nil && hookURL.Host == link.Host { return hook } } } return nil } func convertLabels(from []*gitea.Label) []string { labels := make([]string, len(from)) for i, label := range from { labels[i] = label.Name } return labels } ================================================ FILE: server/forge/gitea/helper_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "bytes" "testing" "code.gitea.io/sdk/gitea" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_parsePush(t *testing.T) { t.Run("Should parse push hook payload", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) hook, err := parsePush(buf) assert.NoError(t, err) assert.Equal(t, "refs/heads/main", hook.Ref) assert.Equal(t, "ef98532add3b2feb7a137426bba1248724367df5", hook.After) assert.Equal(t, "4b2626259b5a97b6b4eab5e6cca66adb986b672b", hook.Before) assert.Equal(t, "http://gitea.golang.org/gordon/hello-world/compare/4b2626259b5a97b6b4eab5e6cca66adb986b672b...ef98532add3b2feb7a137426bba1248724367df5", hook.Compare) assert.Equal(t, "hello-world", hook.Repo.Name) assert.Equal(t, "http://gitea.golang.org/gordon/hello-world", hook.Repo.HTMLURL) assert.Equal(t, "gordon", hook.Repo.Owner.UserName) assert.Equal(t, "gordon/hello-world", hook.Repo.FullName) assert.Equal(t, "gordon@golang.org", hook.Repo.Owner.Email) assert.True(t, hook.Repo.Private) assert.Equal(t, "gordon@golang.org", hook.Pusher.Email) assert.Equal(t, "gordon", hook.Pusher.UserName) assert.Equal(t, "gordon", hook.Sender.UserName) assert.Equal(t, "http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", hook.Sender.AvatarURL) }) t.Run("Should parse tag hook payload", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookTag) hook, err := parsePush(buf) assert.NoError(t, err) assert.Equal(t, "v1.0.0", hook.Ref) assert.Equal(t, "ef98532add3b2feb7a137426bba1248724367df5", hook.Sha) assert.Equal(t, "hello-world", hook.Repo.Name) assert.Equal(t, "http://gitea.golang.org/gordon/hello-world", hook.Repo.HTMLURL) assert.Equal(t, "gordon/hello-world", hook.Repo.FullName) assert.Equal(t, "gordon@golang.org", hook.Repo.Owner.Email) assert.Equal(t, "gordon", hook.Repo.Owner.UserName) assert.True(t, hook.Repo.Private) assert.Equal(t, "gordon", hook.Sender.UserName) assert.Equal(t, "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", hook.Sender.AvatarURL) }) t.Run("Should return a Pipeline struct from a push hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) hook, _ := parsePush(buf) pipeline := pipelineFromPush(hook) assert.Equal(t, model.EventPush, pipeline.Event) assert.Equal(t, hook.After, pipeline.Commit) assert.Equal(t, hook.Ref, pipeline.Ref) assert.Equal(t, hook.Commits[0].URL, pipeline.ForgeURL) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, hook.Commits[0].Message, pipeline.Message) assert.Equal(t, "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", pipeline.Avatar) assert.Equal(t, hook.Sender.UserName, pipeline.Author) assert.Equal(t, []string{"CHANGELOG.md", "app/controller/application.rb"}, pipeline.ChangedFiles) }) t.Run("Should return a Repo struct from a push hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPush) hook, _ := parsePush(buf) repo := toRepo(hook.Repo) assert.Equal(t, hook.Repo.Name, repo.Name) assert.Equal(t, hook.Repo.Owner.UserName, repo.Owner) assert.Equal(t, "gordon/hello-world", repo.FullName) assert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL) }) t.Run("Should return a Pipeline struct from a tag hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookTag) hook, _ := parsePush(buf) pipeline := pipelineFromTag(hook) assert.Equal(t, model.EventTag, pipeline.Event) assert.Equal(t, hook.Sha, pipeline.Commit) assert.Equal(t, "refs/tags/v1.0.0", pipeline.Ref) assert.Empty(t, pipeline.Branch) assert.Equal(t, "http://gitea.golang.org/gordon/hello-world/src/tag/v1.0.0", pipeline.ForgeURL) assert.Equal(t, "created tag v1.0.0", pipeline.Message) }) } func Test_parsePullRequest(t *testing.T) { t.Run("Should parse pull_request hook payload", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) hook, err := parsePullRequest(buf) assert.NoError(t, err) assert.Equal(t, "opened", hook.Action) assert.Equal(t, int64(1), hook.Number) assert.Equal(t, "hello-world", hook.Repo.Name) assert.Equal(t, "http://gitea.golang.org/gordon/hello-world", hook.Repo.HTMLURL) assert.Equal(t, "gordon/hello-world", hook.Repo.FullName) assert.Equal(t, "gordon@golang.org", hook.Repo.Owner.Email) assert.Equal(t, "gordon", hook.Repo.Owner.UserName) assert.True(t, hook.Repo.Private) assert.Equal(t, "gordon", hook.Sender.UserName) assert.Equal(t, "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", hook.Sender.AvatarURL) assert.Equal(t, "Update the README with new information", hook.PullRequest.Title) assert.Equal(t, "please merge", hook.PullRequest.Body) assert.Equal(t, gitea.StateOpen, hook.PullRequest.State) assert.Equal(t, "gordon", hook.PullRequest.Poster.UserName) assert.Equal(t, "main", hook.PullRequest.Base.Name) assert.Equal(t, "main", hook.PullRequest.Base.Ref) assert.Equal(t, "feature/changes", hook.PullRequest.Head.Name) assert.Equal(t, "feature/changes", hook.PullRequest.Head.Ref) }) t.Run("Should return a Pipeline struct from a pull_request hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) hook, _ := parsePullRequest(buf) pipeline := pipelineFromPullRequest(hook) assert.Equal(t, model.EventPull, pipeline.Event) assert.Equal(t, hook.PullRequest.Head.Sha, pipeline.Commit) assert.Equal(t, "refs/pull/1/head", pipeline.Ref) assert.Equal(t, "http://gitea.golang.org/gordon/hello-world/pull/1", pipeline.ForgeURL) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, "feature/changes:main", pipeline.Refspec) assert.Equal(t, hook.PullRequest.Title, pipeline.Message) assert.Equal(t, "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", pipeline.Avatar) assert.Equal(t, hook.PullRequest.Poster.UserName, pipeline.Author) }) t.Run("Should return a Repo struct from a pull_request hook", func(t *testing.T) { buf := bytes.NewBufferString(fixtures.HookPullRequest) hook, _ := parsePullRequest(buf) repo := toRepo(hook.Repo) assert.Equal(t, hook.Repo.Name, repo.Name) assert.Equal(t, hook.Repo.Owner.UserName, repo.Owner) assert.Equal(t, "gordon/hello-world", repo.FullName) assert.Equal(t, hook.Repo.HTMLURL, repo.ForgeURL) }) } func Test_toPerm(t *testing.T) { perms := []gitea.Permission{ { Admin: true, Push: true, Pull: true, }, { Admin: true, Push: true, Pull: false, }, { Admin: true, Push: false, Pull: false, }, } for _, from := range perms { perm := toPerm(&from) assert.Equal(t, from.Pull, perm.Pull) assert.Equal(t, from.Push, perm.Push) assert.Equal(t, from.Admin, perm.Admin) } } func Test_toTeam(t *testing.T) { from := &gitea.Organization{ Name: "woodpecker", AvatarURL: "/avatars/1", } to := toTeam(from, "http://localhost:80") assert.Equal(t, from.Name, to.Login) assert.Equal(t, "http://localhost:80/avatars/1", to.Avatar) } func Test_toRepo(t *testing.T) { from := gitea.Repository{ FullName: "gophers/hello-world", Owner: &gitea.User{ UserName: "gordon", AvatarURL: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, CloneURL: "http://gitea.golang.org/gophers/hello-world.git", HTMLURL: "http://gitea.golang.org/gophers/hello-world", Private: true, DefaultBranch: "main", Permissions: &gitea.Permission{Admin: true}, } repo := toRepo(&from) assert.Equal(t, from.FullName, repo.FullName) assert.Equal(t, from.Owner.UserName, repo.Owner) assert.Equal(t, "hello-world", repo.Name) assert.Equal(t, "main", repo.Branch) assert.Equal(t, from.HTMLURL, repo.ForgeURL) assert.Equal(t, from.CloneURL, repo.Clone) assert.Equal(t, from.Owner.AvatarURL, repo.Avatar) assert.Equal(t, from.Private, repo.IsSCMPrivate) assert.True(t, repo.Perm.Admin) } func Test_fixMalformedAvatar(t *testing.T) { urls := []struct { Before string After string }{ { "http://gitea.golang.org///1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, { "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, { "http://gitea.golang.org/avatars/1", "http://gitea.golang.org/avatars/1", }, { "http://gitea.golang.org//avatars/1", "http://gitea.golang.org/avatars/1", }, } for _, url := range urls { got := fixMalformedAvatar(url.Before) assert.Equal(t, url.After, got) } } func Test_expandAvatar(t *testing.T) { urls := []struct { Before string After string }{ { "/avatars/1", "http://gitea.io/avatars/1", }, { "//1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", }, { "/gitea/avatars/2", "http://gitea.io/gitea/avatars/2", }, } repo := "http://gitea.io/foo/bar" for _, url := range urls { got := expandAvatar(repo, url.Before) assert.Equal(t, url.After, got) } } ================================================ FILE: server/forge/gitea/parse.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "fmt" "io" "net/http" "slices" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( hookEvent = "X-Gitea-Event" hookPush = "push" hookCreated = "create" hookPullRequest = "pull_request" hookRelease = "release" actionOpen = "opened" actionSync = "synchronized" actionClose = "closed" actionEdited = "edited" actionLabelUpdate = "label_updated" actionLabelCleared = "label_cleared" actionMilestoned = "milestoned" actionDeMilestoned = "demilestoned" actionAssigned = "assigned" actionUnAssigned = "unassigned" actionReopen = "reopened" refBranch = "branch" refTag = "tag" ) var actionList = []string{ actionOpen, actionSync, actionClose, actionEdited, actionLabelUpdate, actionMilestoned, actionDeMilestoned, actionLabelCleared, actionAssigned, actionUnAssigned, actionReopen, } func supportedAction(action string) bool { return slices.Contains(actionList, action) } // parseHook parses a Gitea hook from an http.Request and returns // Repo and Pipeline detail. If a hook type is unsupported nil values are returned. func parseHook(r *http.Request) (*model.Repo, *model.Pipeline, error) { hookType := r.Header.Get(hookEvent) switch hookType { case hookPush: return parsePushHook(r.Body) case hookCreated: return parseCreatedHook(r.Body) case hookPullRequest: return parsePullRequestHook(r.Body) case hookRelease: return parseReleaseHook(r.Body) } log.Debug().Msgf("unsupported hook type: '%s'", hookType) return nil, nil, &types.ErrIgnoreEvent{Event: hookType} } // parsePushHook parses a push hook and returns the Repo and Pipeline details. // If the commit type is unsupported nil values are returned. func parsePushHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) { push, err := parsePush(payload) if err != nil { return nil, nil, err } // ignore push events for tags if strings.HasPrefix(push.Ref, "refs/tags/") { return nil, nil, nil } // TODO: is this even needed? if push.RefType == refBranch { return nil, nil, nil } repo = toRepo(push.Repo) pipeline = pipelineFromPush(push) return repo, pipeline, err } // parseCreatedHook parses a push hook and returns the Repo and Pipeline details. // If the commit type is unsupported nil values are returned. func parseCreatedHook(payload io.Reader) (repo *model.Repo, pipeline *model.Pipeline, err error) { push, err := parsePush(payload) if err != nil { return nil, nil, err } if push.RefType != refTag { return nil, nil, nil } repo = toRepo(push.Repo) pipeline = pipelineFromTag(push) return repo, pipeline, nil } // parsePullRequestHook parses a pull_request hook and returns the Repo and Pipeline details. func parsePullRequestHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) { var ( repo *model.Repo pipeline *model.Pipeline ) pr, err := parsePullRequest(payload) if err != nil { return nil, nil, err } if pr.PullRequest == nil { // this should never have happened but it did - so we check return nil, nil, fmt.Errorf("parsed pull_request webhook does not contain pull_request info") } // Only trigger pipelines for supported event types if !supportedAction(pr.Action) { log.Debug().Msgf("pull_request action is '%s'. Only '%s' are supported", pr.Action, strings.Join(actionList, "', '")) return nil, nil, nil } repo = toRepo(pr.Repo) pipeline = pipelineFromPullRequest(pr) // all other actions return the state of labels after the actions where done ... so we should too if pr.Action == actionLabelCleared { pipeline.PullRequestLabels = []string{} } if pr.Action == actionDeMilestoned { pipeline.PullRequestMilestone = "" } for i := range pipeline.EventReason { pipeline.EventReason[i] = common.NormalizeEventReason(pipeline.EventReason[i]) } return repo, pipeline, err } // parseReleaseHook parses a release hook and returns the Repo and Pipeline details. func parseReleaseHook(payload io.Reader) (*model.Repo, *model.Pipeline, error) { var ( repo *model.Repo pipeline *model.Pipeline ) release, err := parseRelease(payload) if err != nil { return nil, nil, err } repo = toRepo(release.Repo) pipeline = pipelineFromRelease(release) return repo, pipeline, err } ================================================ FILE: server/forge/gitea/parse_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import ( "bytes" "net/http" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestGiteaParser(t *testing.T) { pullMetaWebhookRepo := &model.Repo{ ForgeRemoteID: "1234", Owner: "a_nice_user", Name: "hello_world_ci", FullName: "a_nice_user/hello_world_ci", Avatar: "https://gitea.com/avatars/ae32f5573b27f9840942a522d59032b104a2dd15", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci", Clone: "https://gitea.com/a_nice_user/hello_world_ci.git", CloneSSH: "ssh://git@gitea.com:3344/a_nice_user/hello_world_ci.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, } tests := []struct { name string data string event string err error repo *model.Repo pipe *model.Pipeline }{ { name: "should ignore unsupported hook events", data: fixtures.HookPullRequest, event: "issues", err: &types.ErrIgnoreEvent{}, }, { name: "push event should handle a push hook", data: fixtures.HookPushBranch, event: "push", repo: &model.Repo{ ForgeRemoteID: "50820", Owner: "meisam", Name: "woodpecktester", FullName: "meisam/woodpecktester", Avatar: "https://codeberg.org/avatars/96512da76a14cf44e0bb32d1640e878e", ForgeURL: "https://codeberg.org/meisam/woodpecktester", Clone: "https://codeberg.org/meisam/woodpecktester.git", CloneSSH: "git@codeberg.org:meisam/woodpecktester.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "push", Commit: "28c3613ae62640216bea5e7dc71aa65356e4298b", Branch: "fdsafdsa", Ref: "refs/heads/fdsafdsa", Message: "Delete '.woodpecker/.check.yml'\n", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@obermui.de", ForgeURL: "https://codeberg.org/meisam/woodpecktester/commit/28c3613ae62640216bea5e7dc71aa65356e4298b", ChangedFiles: []string{".woodpecker/.check.yml"}, }, }, { name: "push event should extract repository and pipeline details", data: fixtures.HookPush, event: "push", repo: &model.Repo{ ForgeRemoteID: "1", Owner: "gordon", Name: "hello-world", FullName: "gordon/hello-world", Avatar: "http://gitea.golang.org/gordon/hello-world", ForgeURL: "http://gitea.golang.org/gordon/hello-world", Clone: "http://gitea.golang.org/gordon/hello-world.git", CloneSSH: "git@gitea.golang.org:gordon/hello-world.git", IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "gordon", Event: "push", Commit: "ef98532add3b2feb7a137426bba1248724367df5", Branch: "main", Ref: "refs/heads/main", Message: "bump\n", Sender: "gordon", Avatar: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", Email: "gordon@golang.org", ForgeURL: "http://gitea.golang.org/gordon/hello-world/commit/ef98532add3b2feb7a137426bba1248724367df5", ChangedFiles: []string{"CHANGELOG.md", "app/controller/application.rb"}, }, }, { name: "push event should handle multi commit push", data: fixtures.HookPushMulti, event: "push", repo: &model.Repo{ ForgeRemoteID: "6", Owner: "Test-CI", Name: "multi-line-secrets", FullName: "Test-CI/multi-line-secrets", Avatar: "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets", Clone: "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", CloneSSH: "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", Branch: "main", Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "test-user", Event: "push", Commit: "29be01c073851cf0db0c6a466e396b725a670453", Branch: "main", Ref: "refs/heads/main", Message: "add some text\n", Sender: "test-user", Avatar: "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", Email: "test@noreply.localhost", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets/compare/6efcf5b7c98f3e7a491675164b7a2e7acac27941...29be01c073851cf0db0c6a466e396b725a670453", ChangedFiles: []string{"aaa", "aa"}, }, }, { name: "tag event should handle a tag hook", data: fixtures.HookTag, event: "create", repo: &model.Repo{ ForgeRemoteID: "12", Owner: "gordon", Name: "hello-world", FullName: "gordon/hello-world", Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", ForgeURL: "http://gitea.golang.org/gordon/hello-world", Clone: "http://gitea.golang.org/gordon/hello-world.git", CloneSSH: "git@gitea.golang.org:gordon/hello-world.git", Branch: "main", IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "gordon", Event: "tag", Commit: "ef98532add3b2feb7a137426bba1248724367df5", Ref: "refs/tags/v1.0.0", Message: "created tag v1.0.0", Sender: "gordon", Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", Email: "gordon@golang.org", ForgeURL: "http://gitea.golang.org/gordon/hello-world/src/tag/v1.0.0", }, }, { name: "pull-request events should handle a PR hook when PR got created", data: fixtures.HookPullRequest, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "35129377", Owner: "gordon", Name: "hello-world", FullName: "gordon/hello-world", Avatar: "https://secure.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", ForgeURL: "http://gitea.golang.org/gordon/hello-world", Clone: "https://gitea.golang.org/gordon/hello-world.git", CloneSSH: "", Branch: "main", IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "gordon", Event: "pull_request", Commit: "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", Branch: "main", Ref: "refs/pull/1/head", Refspec: "feature/changes:main", Title: "Update the README with new information", Message: "Update the README with new information", Sender: "gordon", Avatar: "http://1.gravatar.com/avatar/8c58a0be77ee441bb8f8595b7f1b4e87", Email: "gordon@golang.org", ForgeURL: "http://gitea.golang.org/gordon/hello-world/pull/1", PullRequestLabels: []string{}, }, }, { name: "pull-request reopen events should handle a PR as it was first created", data: fixtures.HookPullRequestReopened, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "138564", Owner: "test_it", Name: "test_ci_thing", FullName: "test_it/test_ci_thing", Avatar: "https://codeberg.org/avatars/bb6f3159a98a869b43f20b350542f8fb", ForgeURL: "https://codeberg.org/test_it/test_ci_thing", Clone: "https://codeberg.org/test_it/test_ci_thing.git", CloneSSH: "git@codeberg.org/test_it/test_ci_thing.git", Branch: "main", PREnabled: true, IsSCMPrivate: false, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "6543", Event: "pull_request", Commit: "36b5813240a9d2daa29b05046d56a53e18f39a3e", Branch: "main", Ref: "refs/pull/1/head", Refspec: "6543-patch-1:main", Title: "Some ned more AAAA", Message: "Some ned more AAAA", Sender: "6543", Avatar: "https://codeberg.org/avatars/09a234c768cb9bca78f6b2f82d6af173", Email: "6543@noreply.codeberg.org", ForgeURL: "https://codeberg.org/test_it/test_ci_thing/pulls/1", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR hook when PR got updated", data: fixtures.HookPullRequestUpdated, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "6", Owner: "Test-CI", Name: "multi-line-secrets", FullName: "Test-CI/multi-line-secrets", Avatar: "http://127.0.0.1:3000/avatars/5b0a83c2185b3cb1ebceb11062d6c2eb", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets", Clone: "http://127.0.0.1:3000/Test-CI/multi-line-secrets.git", CloneSSH: "ssh://git@127.0.0.1:2200/Test-CI/multi-line-secrets.git", Branch: "main", PREnabled: true, IsSCMPrivate: false, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "test", Event: "pull_request", Commit: "788ed8d02d3b7fcfcf6386dbcbca696aa1d4dc25", Branch: "main", Ref: "refs/pull/2/head", Refspec: "test-patch-1:main", Title: "New Pull", Message: "New Pull", Sender: "test", Avatar: "http://127.0.0.1:3000/avatars/dd46a756faad4727fb679320751f6dea", Email: "test@noreply.localhost", ForgeURL: "http://127.0.0.1:3000/Test-CI/multi-line-secrets/pulls/2", PullRequestLabels: []string{ "Kind/Bug", "Kind/Security", }, }, }, { name: "pull-request events should handle a PR closed hook when PR got closed", data: fixtures.HookPullRequestClosed, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "46534", Owner: "anbraten", Name: "test-repo", FullName: "anbraten/test-repo", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", ForgeURL: "https://gitea.com/anbraten/test-repo", Clone: "https://gitea.com/anbraten/test-repo.git", CloneSSH: "git@gitea.com:anbraten/test-repo.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "pull_request_closed", Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", Branch: "main", Ref: "refs/pull/1/head", Refspec: "anbraten-patch-1:main", Title: "Adjust file", Message: "Adjust file", Sender: "anbraten", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", Email: "anbraten@sender.gitea.com", ForgeURL: "https://gitea.com/anbraten/test-repo/pulls/1", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR title change hook", data: fixtures.HookPullRequestChangeTitle, event: "pull_request", repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"edited"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "Edit pull title :D", Message: "Edit pull title :D", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR body change hook", data: fixtures.HookPullRequestChangeBody, event: "pull_request", repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"edited"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{}, }, }, { name: "pull-request events should ignore a PR add review request hook", data: fixtures.HookPullRequestAddReviewRequest, err: &types.ErrIgnoreEvent{}, }, { name: "pull-request events should ignore a PR add approval review request hook", data: fixtures.HookPullRequestReviewAck, err: &types.ErrIgnoreEvent{}, }, { name: "pull-request events should ignore a PR add reject review request hook", data: fixtures.HookPullRequestReviewDeny, err: &types.ErrIgnoreEvent{}, }, { name: "pull-request events should ignore a PR add comment review request hook", data: fixtures.HookPullRequestReviewComment, err: &types.ErrIgnoreEvent{}, }, { name: "pull-request events should handle a PR add label hook", data: fixtures.HookPullRequestAddLabel, event: "pull_request", // type: pull_request_label repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"label_updated"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug", "help wanted"}, }, }, { name: "pull-request events should handle a PR change label hook", data: fixtures.HookPullRequestChangeLabel, event: "pull_request", // type: pull_request_label repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"label_updated"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug"}, }, }, { name: "pull-request events should handle a PR remove label hook", data: fixtures.HookPullRequestRemoveLabel, event: "pull_request", // type: pull_request_label repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"label_cleared"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{}, }, }, { name: "pull-request events should handle a PR add milestone hook", data: fixtures.HookPullRequestAddMile, event: "pull_request", // type: pull_request_milestone repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"milestoned"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug", "help wanted"}, PullRequestMilestone: "new mile", }, }, { name: "pull-request events should handle a PR change milestone hook", data: fixtures.HookPullRequestChangeMile, event: "pull_request", // type: pull_request_milestone repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"milestoned"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug", "help wanted"}, PullRequestMilestone: "closed mile", }, }, { name: "pull-request events should handle a PR remove milestone hook", data: fixtures.HookPullRequestRemoveMile, event: "pull_request", // type: pull_request_milestone repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"demilestoned"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug", "help wanted"}, PullRequestMilestone: "", }, }, { name: "pull-request events should handle a PR add assignee hook", data: fixtures.HookPullRequestAssigneesAdded, event: "pull_request", // type: pull_request_assign repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"assigned"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug"}, }, }, { name: "pull-request events should handle a PR remove assignee hook", data: fixtures.HookPullRequestAssigneesRemoved, event: "pull_request", // type: pull_request_assign repo: pullMetaWebhookRepo, pipe: &model.Pipeline{ Author: "jony", Event: model.EventPullMetadata, EventReason: []string{"unassigned"}, Commit: "07977177c2cd7d46bad37b8472a9d50e7acb9d1f", Branch: "main", Ref: "refs/pull/7/head", Refspec: "jony-patch-1:main", Title: "somepull", Message: "somepull", Sender: "a_nice_user", Avatar: "https://gitea.com/avatars/81027235e996f5e3ef6257152357b85d94171a2e", Email: "a_nice_user@noreply.example.org", ForgeURL: "https://gitea.com/a_nice_user/hello_world_ci/pulls/7", PullRequestLabels: []string{"bug"}, }, }, { name: "pull-request events should handle a PR closed hook when PR was merged", data: fixtures.HookPullRequestMerged, event: "pull_request", repo: &model.Repo{ ForgeRemoteID: "46534", Owner: "anbraten", Name: "test-repo", FullName: "anbraten/test-repo", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", ForgeURL: "https://gitea.com/anbraten/test-repo", Clone: "https://gitea.com/anbraten/test-repo.git", CloneSSH: "git@gitea.com:anbraten/test-repo.git", Branch: "main", PREnabled: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "pull_request_closed", Commit: "d555a5dd07f4d0148a58d4686ec381502ae6a2d4", Branch: "main", Ref: "refs/pull/1/head", Refspec: "anbraten-patch-1:main", Title: "Adjust file", Message: "Adjust file", Sender: "anbraten", Avatar: "https://seccdn.libravatar.org/avatar/fc9b6fe77c6b732a02925a62a81f05a0?d=identicon", Email: "anbraten@noreply.gitea.com", ForgeURL: "https://gitea.com/anbraten/test-repo/pulls/1", PullRequestLabels: []string{}, }, }, { name: "release events should handle release hook", data: fixtures.HookRelease, event: "release", repo: &model.Repo{ ForgeRemoteID: "77", Owner: "anbraten", Name: "demo", FullName: "anbraten/demo", Avatar: "https://git.xxx/user/avatar/anbraten/-1", ForgeURL: "https://git.xxx/anbraten/demo", Clone: "https://git.xxx/anbraten/demo.git", CloneSSH: "ssh://git@git.xxx:22/anbraten/demo.git", Branch: "main", PREnabled: true, IsSCMPrivate: true, Perm: &model.Perm{ Pull: true, Push: true, Admin: true, }, }, pipe: &model.Pipeline{ Author: "anbraten", Event: "release", Branch: "main", Ref: "refs/tags/0.0.5", Message: "created release Version 0.0.5", Sender: "anbraten", Avatar: "https://git.xxx/user/avatar/anbraten/-1", Email: "anbraten@noreply.xxx", ForgeURL: "https://git.xxx/anbraten/demo/releases/tag/0.0.5", }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, "/api/hook", bytes.NewBufferString(tc.data)) req.Header = http.Header{} req.Header.Set(hookEvent, tc.event) r, p, err := parseHook(req) if tc.err != nil { assert.ErrorIs(t, err, tc.err) } else if assert.NoError(t, err) { assert.EqualValues(t, tc.repo, r) p.Timestamp = 0 assert.EqualValues(t, tc.pipe, p) } }) } } ================================================ FILE: server/forge/gitea/types.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitea import "code.gitea.io/sdk/gitea" type pushHook struct { Sha string `json:"sha"` Ref string `json:"ref"` Before string `json:"before"` After string `json:"after"` Compare string `json:"compare_url"` RefType string `json:"ref_type"` Pusher *gitea.User `json:"pusher"` Repo *gitea.Repository `json:"repository"` Commits []gitea.PayloadCommit `json:"commits"` HeadCommit gitea.PayloadCommit `json:"head_commit"` Sender *gitea.User `json:"sender"` } type pullRequestHook struct { Action string `json:"action"` Number int64 `json:"number"` PullRequest *gitea.PullRequest `json:"pull_request"` Repo *gitea.Repository `json:"repository"` Sender *gitea.User `json:"sender"` } type releaseHook struct { Action string `json:"action"` Repo *gitea.Repository `json:"repository"` Sender *gitea.User `json:"sender"` Release *gitea.Release } ================================================ FILE: server/forge/github/convert.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package github import ( "fmt" "github.com/google/go-github/v86/github" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( statusPending = "pending" statusSuccess = "success" statusFailure = "failure" statusError = "error" ) const ( descPending = "this pipeline is pending" descSuccess = "the pipeline was successful" descFailure = "the pipeline failed" descBlocked = "the pipeline requires approval" descDeclined = "the pipeline was rejected" descError = "oops, something went wrong" ) const ( headRefs = "refs/pull/%d/head" // pull request unmerged mergeRefs = "refs/pull/%d/merge" // pull request merged with base refSpec = "%s:%s" ) // convertStatus is a helper function used to convert a Woodpecker status to a // GitHub commit status. func convertStatus(status model.StatusValue) string { switch status { case model.StatusPending, model.StatusRunning, model.StatusBlocked, model.StatusSkipped, model.StatusCanceled: return statusPending case model.StatusFailure, model.StatusDeclined: return statusFailure case model.StatusSuccess: return statusSuccess default: return statusError } } // convertDesc is a helper function used to convert a Woodpecker status to a // GitHub status description. func convertDesc(status model.StatusValue) string { switch status { case model.StatusPending, model.StatusRunning: return descPending case model.StatusSuccess: return descSuccess case model.StatusFailure: return descFailure case model.StatusBlocked: return descBlocked case model.StatusDeclined: return descDeclined default: return descError } } // convertRepo is a helper function used to convert a GitHub repository // structure to the common Woodpecker repository structure. func convertRepo(from *github.Repository) *model.Repo { repo := &model.Repo{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(from.GetID())), Name: from.GetName(), FullName: from.GetFullName(), ForgeURL: from.GetHTMLURL(), IsSCMPrivate: from.GetPrivate(), Clone: from.GetCloneURL(), CloneSSH: from.GetSSHURL(), Branch: from.GetDefaultBranch(), Owner: from.GetOwner().GetLogin(), Avatar: from.GetOwner().GetAvatarURL(), Perm: convertPerm(from.GetPermissions()), PREnabled: true, } return repo } // convertPerm is a helper function used to convert a GitHub repository // permissions to the common Woodpecker permissions structure. func convertPerm(perm *github.RepositoryPermissions) *model.Perm { return &model.Perm{ Admin: perm.GetAdmin(), Push: perm.GetPush(), Pull: perm.GetPull(), } } // convertRepoList is a helper function used to convert a GitHub repository // list to the common Woodpecker repository structure. func convertRepoList(from []*github.Repository) []*model.Repo { var repos []*model.Repo for _, repo := range from { repos = append(repos, convertRepo(repo)) } return repos } // convertTeamList is a helper function used to convert a GitHub team list to // the common Woodpecker repository structure. func convertTeamList(from []*github.Organization) []*model.Team { var teams []*model.Team for _, team := range from { teams = append(teams, convertTeam(team)) } return teams } // convertTeam is a helper function used to convert a GitHub team structure // to the common Woodpecker repository structure. func convertTeam(from *github.Organization) *model.Team { return &model.Team{ Login: from.GetLogin(), Avatar: from.GetAvatarURL(), } } // convertRepoHook is a helper function used to extract the Repository details // from a webhook and convert to the common Woodpecker repository structure. func convertRepoHook(eventRepo *github.PushEventRepository) *model.Repo { repo := &model.Repo{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(eventRepo.GetID())), Owner: eventRepo.GetOwner().GetLogin(), Name: eventRepo.GetName(), FullName: eventRepo.GetFullName(), ForgeURL: eventRepo.GetHTMLURL(), IsSCMPrivate: eventRepo.GetPrivate(), Clone: eventRepo.GetCloneURL(), CloneSSH: eventRepo.GetSSHURL(), Branch: eventRepo.GetDefaultBranch(), PREnabled: true, } if repo.FullName == "" { repo.FullName = repo.Owner + "/" + repo.Name } return repo } // convertLabels is a helper function used to convert a GitHub label list to // the common Woodpecker label structure. func convertLabels(from []*github.Label) []string { labels := make([]string, len(from)) for i, label := range from { labels[i] = *label.Name } return labels } ================================================ FILE: server/forge/github/convert_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package github import ( "testing" "github.com/google/go-github/v86/github" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Test_convertStatus(t *testing.T) { assert.Equal(t, statusSuccess, convertStatus(model.StatusSuccess)) assert.Equal(t, statusPending, convertStatus(model.StatusPending)) assert.Equal(t, statusPending, convertStatus(model.StatusRunning)) assert.Equal(t, statusFailure, convertStatus(model.StatusFailure)) assert.Equal(t, statusError, convertStatus(model.StatusKilled)) assert.Equal(t, statusError, convertStatus(model.StatusError)) } func Test_convertDesc(t *testing.T) { assert.Equal(t, descSuccess, convertDesc(model.StatusSuccess)) assert.Equal(t, descPending, convertDesc(model.StatusPending)) assert.Equal(t, descPending, convertDesc(model.StatusRunning)) assert.Equal(t, descFailure, convertDesc(model.StatusFailure)) assert.Equal(t, descError, convertDesc(model.StatusKilled)) assert.Equal(t, descError, convertDesc(model.StatusError)) } func Test_convertRepoList(t *testing.T) { from := []*github.Repository{ { Private: github.Ptr(false), FullName: github.Ptr("octocat/hello-world"), Name: github.Ptr("hello-world"), Owner: &github.User{ AvatarURL: github.Ptr("http://..."), Login: github.Ptr("octocat"), }, HTMLURL: github.Ptr("https://github.com/octocat/hello-world"), CloneURL: github.Ptr("https://github.com/octocat/hello-world.git"), Permissions: &github.RepositoryPermissions{ Admin: github.Ptr(true), Push: github.Ptr(true), Pull: github.Ptr(true), }, }, } to := convertRepoList(from) assert.Equal(t, "http://...", to[0].Avatar) assert.Equal(t, "octocat/hello-world", to[0].FullName) assert.Equal(t, "octocat", to[0].Owner) assert.Equal(t, "hello-world", to[0].Name) } func Test_convertRepo(t *testing.T) { from := github.Repository{ FullName: github.Ptr("octocat/hello-world"), Name: github.Ptr("hello-world"), HTMLURL: github.Ptr("https://github.com/octocat/hello-world"), CloneURL: github.Ptr("https://github.com/octocat/hello-world.git"), DefaultBranch: github.Ptr("develop"), Private: github.Ptr(true), Owner: &github.User{ AvatarURL: github.Ptr("http://..."), Login: github.Ptr("octocat"), }, Permissions: &github.RepositoryPermissions{ Admin: github.Ptr(true), Push: github.Ptr(true), Pull: github.Ptr(true), }, } to := convertRepo(&from) assert.Equal(t, "http://...", to.Avatar) assert.Equal(t, "octocat/hello-world", to.FullName) assert.Equal(t, "octocat", to.Owner) assert.Equal(t, "hello-world", to.Name) assert.Equal(t, "develop", to.Branch) assert.True(t, to.IsSCMPrivate) assert.Equal(t, "https://github.com/octocat/hello-world.git", to.Clone) assert.Equal(t, "https://github.com/octocat/hello-world", to.ForgeURL) } func Test_convertPerm(t *testing.T) { from := &github.Repository{ Permissions: &github.RepositoryPermissions{ Admin: github.Ptr(true), Push: github.Ptr(true), Pull: github.Ptr(true), }, } to := convertPerm(from.GetPermissions()) assert.True(t, to.Push) assert.True(t, to.Pull) assert.True(t, to.Admin) } func Test_convertTeam(t *testing.T) { from := &github.Organization{ Login: github.Ptr("octocat"), AvatarURL: github.Ptr("http://..."), } to := convertTeam(from) assert.Equal(t, "octocat", to.Login) assert.Equal(t, "http://...", to.Avatar) } func Test_convertTeamList(t *testing.T) { from := []*github.Organization{ { Login: github.Ptr("octocat"), AvatarURL: github.Ptr("http://..."), }, } to := convertTeamList(from) assert.Equal(t, "octocat", to[0].Login) assert.Equal(t, "http://...", to[0].Avatar) } func Test_convertRepoHook(t *testing.T) { t.Run("should convert a repository from webhook", func(t *testing.T) { from := &github.PushEventRepository{Owner: &github.User{}} from.Owner.Login = github.Ptr("octocat") from.Owner.Name = github.Ptr("octocat") from.Name = github.Ptr("hello-world") from.FullName = github.Ptr("octocat/hello-world") from.Private = github.Ptr(true) from.HTMLURL = github.Ptr("https://github.com/octocat/hello-world") from.CloneURL = github.Ptr("https://github.com/octocat/hello-world.git") from.DefaultBranch = github.Ptr("develop") repo := convertRepoHook(from) assert.Equal(t, *from.Owner.Login, repo.Owner) assert.Equal(t, *from.Name, repo.Name) assert.Equal(t, *from.FullName, repo.FullName) assert.Equal(t, *from.Private, repo.IsSCMPrivate) assert.Equal(t, *from.HTMLURL, repo.ForgeURL) assert.Equal(t, *from.CloneURL, repo.Clone) assert.Equal(t, *from.DefaultBranch, repo.Branch) }) } ================================================ FILE: server/forge/github/fixtures/HookDeploy.json ================================================ { "deployment": { "url": "https://api.github.com/repos/baxterthehacker/public-repo/deployments/710692", "id": 710692, "sha": "9049f1265b7d61be4a8904a9a27120d2064dab3b", "ref": "main", "task": "deploy", "payload": {}, "environment": "production", "description": null, "creator": { "login": "baxterthehacker", "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" } }, "repository": { "id": 35129377, "name": "public-repo", "full_name": "baxterthehacker/public-repo", "owner": { "login": "baxterthehacker", "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" }, "private": true, "html_url": "https://github.com/baxterthehacker/public-repo", "clone_url": "https://github.com/baxterthehacker/public-repo.git", "default_branch": "main" }, "sender": { "login": "baxterthehacker", "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" } } ================================================ FILE: server/forge/github/fixtures/HookPullRequest.json ================================================ { "action": "opened", "number": 1, "pull_request": { "url": "https://api.github.com/repos/baxterthehacker/public-repo/pulls/1", "html_url": "https://github.com/baxterthehacker/public-repo/pull/1", "number": 1, "state": "open", "title": "Update the README with new information", "user": { "login": "baxterthehacker", "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" }, "base": { "label": "baxterthehacker:main", "ref": "main", "sha": "9353195a19e45482665306e466c832c46560532d" }, "head": { "label": "baxterthehacker:changes", "ref": "changes", "sha": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c" } }, "repository": { "id": 35129377, "name": "public-repo", "full_name": "baxterthehacker/public-repo", "owner": { "login": "baxterthehacker", "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" }, "private": true, "html_url": "https://github.com/baxterthehacker/public-repo", "clone_url": "https://github.com/baxterthehacker/public-repo.git", "default_branch": "main" }, "sender": { "login": "octocat", "avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3" } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestAssigneeAdded.json ================================================ { "action": "assigned", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-30T00:05:47Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "assignees": [ { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } ], "requested_reviewers": [], "requested_teams": [], "labels": [ { "id": 9024465370, "node_id": "LA_kwDOPU9UaM8AAAACGeZp2g", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/bug", "name": "bug", "color": "d73a4a", "default": true, "description": "Something isn't working" } ], "milestone": null, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "assignee": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestAssigneeRemoved.json ================================================ { "action": "unassigned", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-30T00:06:11Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [ { "id": 9024465370, "node_id": "LA_kwDOPU9UaM8AAAACGeZp2g", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/bug", "name": "bug", "color": "d73a4a", "default": true, "description": "Something isn't working" } ], "milestone": null, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "assignee": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestClosed.json ================================================ { "action": "closed", "number": 62, "pull_request": { "url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62", "id": 1630965956, "node_id": "PR_kwDOIl-VNc5hNpDE", "html_url": "https://github.com/anbraten/test-ready-release-go/pull/62", "diff_url": "https://github.com/anbraten/test-ready-release-go/pull/62.diff", "patch_url": "https://github.com/anbraten/test-ready-release-go/pull/62.patch", "issue_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62", "number": 62, "state": "closed", "locked": false, "title": "Change file", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "body": null, "created_at": "2023-12-05T18:13:16Z", "updated_at": "2023-12-05T18:14:13Z", "closed_at": "2023-12-05T18:14:13Z", "merged_at": null, "merge_commit_sha": "79fd3b2a13c462ef9b3169b9dee9cb39605fda1b", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [], "milestone": null, "draft": false, "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits", "review_comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments", "review_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf", "head": { "label": "anbraten:anbraten-patch-3", "ref": "anbraten-patch-3", "sha": "c88b9ee719285134957cbc698c9b7ef9b78007bf", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "repo": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:13:17Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "anbraten:main", "ref": "main", "sha": "26fd46e0d1237cdabfe84ec6a0f37466fc716952", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "repo": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:13:17Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62" }, "html": { "href": "https://github.com/anbraten/test-ready-release-go/pull/62" }, "issue": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62" }, "comments": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments" }, "review_comments": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments" }, "review_comment": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits" }, "statuses": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": false, "mergeable_state": "clean", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "repository": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:13:17Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main" }, "sender": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestEdited.json ================================================ { "action": "edited", "number": 62, "pull_request": { "url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62", "id": 1630965956, "node_id": "PR_kwDOIl-VNc5hNpDE", "html_url": "https://github.com/anbraten/test-ready-release-go/pull/62", "diff_url": "https://github.com/anbraten/test-ready-release-go/pull/62.diff", "patch_url": "https://github.com/anbraten/test-ready-release-go/pull/62.patch", "issue_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62", "number": 62, "state": "open", "locked": false, "title": "Change file", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "body": null, "created_at": "2023-12-05T18:13:16Z", "updated_at": "2023-12-05T18:14:13Z", "closed_at": "2023-12-05T18:14:13Z", "merged_at": null, "merge_commit_sha": "79fd3b2a13c462ef9b3169b9dee9cb39605fda1b", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [], "milestone": null, "draft": false, "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits", "review_comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments", "review_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf", "head": { "label": "anbraten:anbraten-patch-3", "ref": "anbraten-patch-3", "sha": "c88b9ee719285134957cbc698c9b7ef9b78007bf", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "repo": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:13:17Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "anbraten:main", "ref": "main", "sha": "26fd46e0d1237cdabfe84ec6a0f37466fc716952", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "repo": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:13:17Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62" }, "html": { "href": "https://github.com/anbraten/test-ready-release-go/pull/62" }, "issue": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62" }, "comments": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments" }, "review_comments": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments" }, "review_comment": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits" }, "statuses": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": false, "mergeable_state": "clean", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "repository": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:13:17Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main" }, "sender": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestLabelAdded.json ================================================ { "action": "labeled", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-29T23:46:36Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [ { "id": 9024465376, "node_id": "LA_kwDOPU9UaM8AAAACGeZp4A", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/documentation", "name": "documentation", "color": "0075ca", "default": true, "description": "Improvements or additions to documentation" }, { "id": 9024465382, "node_id": "LA_kwDOPU9UaM8AAAACGeZp5g", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/enhancement", "name": "enhancement", "color": "a2eeef", "default": true, "description": "New feature or request" } ], "milestone": { "url": "https://api.github.com/repos/6543/test_ci_tmp/milestones/2", "id": 13392101, "node_id": "MI_kwDOPU9UaM4AzFjl", "number": 2, "title": "open mile", "description": "ongoing", "creator": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "open_issues": 1, "closed_issues": 0, "state": "open", "created_at": "2025-07-29T23:46:08Z", "updated_at": "2025-07-29T23:46:29Z", "due_on": null, "closed_at": null }, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "label": { "id": 9024465376, "node_id": "LA_kwDOPU9UaM8AAAACGeZp4A", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/documentation", "name": "documentation", "color": "0075ca", "default": true, "description": "Improvements or additions to documentation" }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestLabelRemoved.json ================================================ { "action": "unlabeled", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-29T23:54:55Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [ { "id": 9024465370, "node_id": "LA_kwDOPU9UaM8AAAACGeZp2g", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/bug", "name": "bug", "color": "d73a4a", "default": true, "description": "Something isn't working" } ], "milestone": { "url": "https://api.github.com/repos/6543/test_ci_tmp/milestones/2", "id": 13392101, "node_id": "MI_kwDOPU9UaM4AzFjl", "number": 2, "title": "open mile", "description": "ongoing", "creator": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "open_issues": 1, "closed_issues": 0, "state": "open", "created_at": "2025-07-29T23:46:08Z", "updated_at": "2025-07-29T23:46:29Z", "due_on": null, "closed_at": null }, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "label": { "id": 9024465380, "node_id": "LA_kwDOPU9UaM8AAAACGeZp5A", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/duplicate", "name": "duplicate", "color": "cfd3d7", "default": true, "description": "This issue or pull request already exists" }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestLabelsCleared.json ================================================ { "action": "unlabeled", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "html_url": "https://github.com/6543/test_ci_tmp/pull/1", "diff_url": "https://github.com/6543/test_ci_tmp/pull/1.diff", "patch_url": "https://github.com/6543/test_ci_tmp/pull/1.patch", "issue_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/1", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-09-22T12:34:38Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "c449d9571e3cfabc9ee42cc6725196497e16151a", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [], "milestone": null, "draft": false, "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits", "review_comments_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments", "review_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e", "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "label": { "id": 9024465382, "node_id": "LA_kwDOPU9UaM8AAAACGeZp5g", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/enhancement", "name": "enhancement", "color": "a2eeef", "default": true, "description": "New feature or request" }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestMerged.json ================================================ { "action": "closed", "number": 62, "pull_request": { "url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62", "id": 1630965956, "node_id": "PR_kwDOIl-VNc5hNpDE", "html_url": "https://github.com/anbraten/test-ready-release-go/pull/62", "diff_url": "https://github.com/anbraten/test-ready-release-go/pull/62.diff", "patch_url": "https://github.com/anbraten/test-ready-release-go/pull/62.patch", "issue_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62", "number": 62, "state": "closed", "locked": false, "title": "Change file", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "body": null, "created_at": "2023-12-05T18:13:16Z", "updated_at": "2023-12-05T18:34:19Z", "closed_at": "2023-12-05T18:34:19Z", "merged_at": "2023-12-05T18:34:19Z", "merge_commit_sha": "473d70eb7c50a54ae62bf9b124efa1c3eb245be8", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [], "milestone": null, "draft": false, "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits", "review_comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments", "review_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf", "head": { "label": "anbraten:anbraten-patch-3", "ref": "anbraten-patch-3", "sha": "c88b9ee719285134957cbc698c9b7ef9b78007bf", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "repo": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:34:19Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "anbraten:main", "ref": "main", "sha": "26fd46e0d1237cdabfe84ec6a0f37466fc716952", "user": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "repo": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:34:19Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62" }, "html": { "href": "https://github.com/anbraten/test-ready-release-go/pull/62" }, "issue": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62" }, "comments": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/62/comments" }, "review_comments": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/comments" }, "review_comment": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls/62/commits" }, "statuses": { "href": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/c88b9ee719285134957cbc698c9b7ef9b78007bf" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": true, "mergeable": null, "rebaseable": null, "mergeable_state": "unknown", "merged_by": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "repository": { "id": 576689461, "node_id": "R_kgDOIl-VNQ", "name": "test-ready-release-go", "full_name": "anbraten/test-ready-release-go", "private": false, "owner": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false }, "html_url": "https://github.com/anbraten/test-ready-release-go", "description": null, "fork": false, "url": "https://api.github.com/repos/anbraten/test-ready-release-go", "forks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/forks", "keys_url": "https://api.github.com/repos/anbraten/test-ready-release-go/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/anbraten/test-ready-release-go/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/anbraten/test-ready-release-go/teams", "hooks_url": "https://api.github.com/repos/anbraten/test-ready-release-go/hooks", "issue_events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/events{/number}", "events_url": "https://api.github.com/repos/anbraten/test-ready-release-go/events", "assignees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/assignees{/user}", "branches_url": "https://api.github.com/repos/anbraten/test-ready-release-go/branches{/branch}", "tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/tags", "blobs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/refs{/sha}", "trees_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/anbraten/test-ready-release-go/statuses/{sha}", "languages_url": "https://api.github.com/repos/anbraten/test-ready-release-go/languages", "stargazers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/stargazers", "contributors_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contributors", "subscribers_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscribers", "subscription_url": "https://api.github.com/repos/anbraten/test-ready-release-go/subscription", "commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/commits{/sha}", "git_commits_url": "https://api.github.com/repos/anbraten/test-ready-release-go/git/commits{/sha}", "comments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/comments{/number}", "issue_comment_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues/comments{/number}", "contents_url": "https://api.github.com/repos/anbraten/test-ready-release-go/contents/{+path}", "compare_url": "https://api.github.com/repos/anbraten/test-ready-release-go/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/anbraten/test-ready-release-go/merges", "archive_url": "https://api.github.com/repos/anbraten/test-ready-release-go/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/anbraten/test-ready-release-go/downloads", "issues_url": "https://api.github.com/repos/anbraten/test-ready-release-go/issues{/number}", "pulls_url": "https://api.github.com/repos/anbraten/test-ready-release-go/pulls{/number}", "milestones_url": "https://api.github.com/repos/anbraten/test-ready-release-go/milestones{/number}", "notifications_url": "https://api.github.com/repos/anbraten/test-ready-release-go/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/anbraten/test-ready-release-go/labels{/name}", "releases_url": "https://api.github.com/repos/anbraten/test-ready-release-go/releases{/id}", "deployments_url": "https://api.github.com/repos/anbraten/test-ready-release-go/deployments", "created_at": "2022-12-10T16:59:42Z", "updated_at": "2023-07-11T17:00:26Z", "pushed_at": "2023-12-05T18:34:19Z", "git_url": "git://github.com/anbraten/test-ready-release-go.git", "ssh_url": "git@github.com:anbraten/test-ready-release-go.git", "clone_url": "https://github.com/anbraten/test-ready-release-go.git", "svn_url": "https://github.com/anbraten/test-ready-release-go", "homepage": null, "size": 11198, "stargazers_count": 0, "watchers_count": 0, "language": "Go", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main" }, "sender": { "login": "anbraten", "id": 6918444, "node_id": "MDQ6VXNlcjY5MTg0NDQ=", "avatar_url": "https://avatars.githubusercontent.com/u/6918444?v=4", "gravatar_id": "", "url": "https://api.github.com/users/anbraten", "html_url": "https://github.com/anbraten", "followers_url": "https://api.github.com/users/anbraten/followers", "following_url": "https://api.github.com/users/anbraten/following{/other_user}", "gists_url": "https://api.github.com/users/anbraten/gists{/gist_id}", "starred_url": "https://api.github.com/users/anbraten/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/anbraten/subscriptions", "organizations_url": "https://api.github.com/users/anbraten/orgs", "repos_url": "https://api.github.com/users/anbraten/repos", "events_url": "https://api.github.com/users/anbraten/events{/privacy}", "received_events_url": "https://api.github.com/users/anbraten/received_events", "type": "User", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestMilestoneAdded.json ================================================ { "action": "milestoned", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-29T23:46:29Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [], "milestone": { "url": "https://api.github.com/repos/6543/test_ci_tmp/milestones/2", "id": 13392101, "node_id": "MI_kwDOPU9UaM4AzFjl", "number": 2, "title": "open mile", "description": "ongoing", "creator": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "open_issues": 1, "closed_issues": 0, "state": "open", "created_at": "2025-07-29T23:46:08Z", "updated_at": "2025-07-29T23:46:29Z", "due_on": null, "closed_at": null }, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "milestone": { "url": "https://api.github.com/repos/6543/test_ci_tmp/milestones/2", "id": 13392101, "node_id": "MI_kwDOPU9UaM4AzFjl", "number": 2, "title": "open mile", "description": "ongoing", "creator": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "open_issues": 1, "closed_issues": 0, "state": "open", "created_at": "2025-07-29T23:46:08Z", "updated_at": "2025-07-29T23:46:29Z", "due_on": null, "closed_at": null }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestMilestoneRemoved.json ================================================ { "action": "demilestoned", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-30T00:01:25Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [ { "id": 9024465370, "node_id": "LA_kwDOPU9UaM8AAAACGeZp2g", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/bug", "name": "bug", "color": "d73a4a", "default": true, "description": "Something isn't working" } ], "milestone": null, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "milestone": { "url": "https://api.github.com/repos/6543/test_ci_tmp/milestones/1", "id": 13392100, "node_id": "MI_kwDOPU9UaM4AzFjk", "number": 1, "title": "closed mile", "description": "", "creator": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "open_issues": 0, "closed_issues": 0, "state": "closed", "created_at": "2025-07-29T23:45:30Z", "updated_at": "2025-07-30T00:01:25Z", "due_on": "2029-03-16T07:00:00Z", "closed_at": "2025-07-29T23:45:35Z" }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestReopened.json ================================================ { "action": "reopened", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "html_url": "https://github.com/6543/test_ci_tmp/pull/1", "diff_url": "https://github.com/6543/test_ci_tmp/pull/1.diff", "patch_url": "https://github.com/6543/test_ci_tmp/pull/1.patch", "issue_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/1", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-08-05T14:34:19Z", "closed_at": null, "merged_at": null, "merge_commit_sha": null, "assignee": null, "assignees": [], "requested_reviewers": [], "requested_teams": [], "labels": [ { "id": 9024465376, "node_id": "LA_kwDOPU9UaM8AAAACGeZp4A", "url": "https://api.github.com/repos/6543/test_ci_tmp/labels/documentation", "name": "documentation", "color": "0075ca", "default": true, "description": "Improvements or additions to documentation" } ], "milestone": null, "draft": false, "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits", "review_comments_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments", "review_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e", "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": null, "rebaseable": null, "mergeable_state": "unknown", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPullRequestReviewRequested.json ================================================ { "action": "review_requested", "number": 1, "pull_request": { "url": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1", "id": 2705176047, "node_id": "PR_kwDOPU9UaM6hPbXv", "number": 1, "state": "open", "locked": false, "title": "Some ned more AAAA", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "body": "yeaaa", "created_at": "2025-07-29T20:00:54Z", "updated_at": "2025-07-29T23:20:52Z", "closed_at": null, "merged_at": null, "merge_commit_sha": "b5fafd8b1c043723a38c99775bc807075bce9235", "assignee": null, "assignees": [], "requested_reviewers": [ { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } ], "requested_teams": [], "labels": [], "milestone": null, "draft": false, "head": { "label": "6543:6543-patch-1", "ref": "6543-patch-1", "sha": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "base": { "label": "6543:main", "ref": "main", "sha": "67012991d6c69b1c58378346fca366b864d8d1a1", "user": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "repo": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main", "allow_squash_merge": true, "allow_merge_commit": true, "allow_rebase_merge": true, "allow_auto_merge": false, "delete_branch_on_merge": false, "allow_update_branch": false, "use_squash_pr_title_as_default": false, "squash_merge_commit_message": "COMMIT_MESSAGES", "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", "merge_commit_message": "PR_TITLE", "merge_commit_title": "MERGE_MESSAGE" } }, "_links": { "self": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1" }, "html": { "href": "https://github.com/6543/test_ci_tmp/pull/1" }, "issue": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1" }, "comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/issues/1/comments" }, "review_comments": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/comments" }, "review_comment": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/comments{/number}" }, "commits": { "href": "https://api.github.com/repos/6543/test_ci_tmp/pulls/1/commits" }, "statuses": { "href": "https://api.github.com/repos/6543/test_ci_tmp/statuses/36b5813240a9d2daa29b05046d56a53e18f39a3e" } }, "author_association": "OWNER", "auto_merge": null, "active_lock_reason": null, "merged": false, "mergeable": true, "rebaseable": true, "mergeable_state": "unstable", "merged_by": null, "comments": 0, "review_comments": 0, "maintainer_can_modify": false, "commits": 1, "additions": 1, "deletions": 0, "changed_files": 1 }, "requested_reviewer": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false }, "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "gravatar_id": "", "url": "https://api.github.com/users/6543", "type": "User", "user_view_type": "public", "site_admin": false }, "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "created_at": "2025-07-29T19:35:41Z", "updated_at": "2025-07-29T19:36:23Z", "pushed_at": "2025-07-29T19:36:21Z", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "archived": false, "disabled": false, "open_issues_count": 1, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 1, "watchers": 0, "default_branch": "main" }, "sender": { "login": "demoaccount2-commits", "id": 223550959, "node_id": "U_kgDODVMd7w", "gravatar_id": "", "url": "https://api.github.com/users/demoaccount2-commits", "type": "User", "user_view_type": "public", "site_admin": false } } ================================================ FILE: server/forge/github/fixtures/HookPush.json ================================================ { "ref": "refs/heads/main", "before": "2f780193b136b72bfea4eeb640786a8c4450c7a2", "after": "366701fde727cb7a9e7f21eb88264f59f6f9b89c", "repository": { "id": 179344069, "node_id": "MDEwOlJlcG9zaXRvcnkxNzkzNDQwNjk=", "name": "woodpecker", "full_name": "woodpecker-ci/woodpecker", "private": false, "owner": { "name": "woodpecker-ci", "email": null, "login": "woodpecker-ci", "id": 84780935, "node_id": "MDEyOk9yZ2FuaXphdGlvbjg0NzgwOTM1", "avatar_url": "https://avatars.githubusercontent.com/u/84780935?v=4", "gravatar_id": "", "url": "https://api.github.com/users/woodpecker-ci", "html_url": "https://github.com/woodpecker-ci", "followers_url": "https://api.github.com/users/woodpecker-ci/followers", "following_url": "https://api.github.com/users/woodpecker-ci/following{/other_user}", "gists_url": "https://api.github.com/users/woodpecker-ci/gists{/gist_id}", "starred_url": "https://api.github.com/users/woodpecker-ci/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/woodpecker-ci/subscriptions", "organizations_url": "https://api.github.com/users/woodpecker-ci/orgs", "repos_url": "https://api.github.com/users/woodpecker-ci/repos", "events_url": "https://api.github.com/users/woodpecker-ci/events{/privacy}", "received_events_url": "https://api.github.com/users/woodpecker-ci/received_events", "type": "Organization", "site_admin": false }, "html_url": "https://github.com/woodpecker-ci/woodpecker", "description": "Woodpecker is a simple, yet powerful CI/CD engine with great extensibility.", "fork": false, "url": "https://github.com/woodpecker-ci/woodpecker", "forks_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/forks", "keys_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/teams", "hooks_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/hooks", "issue_events_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/issues/events{/number}", "events_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/events", "assignees_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/assignees{/user}", "branches_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/branches{/branch}", "tags_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/tags", "blobs_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/git/refs{/sha}", "trees_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/statuses/{sha}", "languages_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/languages", "stargazers_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/stargazers", "contributors_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/contributors", "subscribers_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/subscribers", "subscription_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/subscription", "commits_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/commits{/sha}", "git_commits_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/git/commits{/sha}", "comments_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/comments{/number}", "issue_comment_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/issues/comments{/number}", "contents_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/contents/{+path}", "compare_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/merges", "archive_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/downloads", "issues_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/issues{/number}", "pulls_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/pulls{/number}", "milestones_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/milestones{/number}", "notifications_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/labels{/name}", "releases_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/releases{/id}", "deployments_url": "https://api.github.com/repos/woodpecker-ci/woodpecker/deployments", "created_at": 1554314798, "updated_at": "2022-01-16T20:19:33Z", "pushed_at": 1642370257, "git_url": "git://github.com/woodpecker-ci/woodpecker.git", "ssh_url": "git@github.com:woodpecker-ci/woodpecker.git", "clone_url": "https://github.com/woodpecker-ci/woodpecker.git", "svn_url": "https://github.com/woodpecker-ci/woodpecker", "homepage": "https://woodpecker-ci.org", "size": 81324, "stargazers_count": 659, "watchers_count": 659, "language": "Go", "has_issues": true, "has_projects": false, "has_downloads": true, "has_wiki": false, "has_pages": false, "forks_count": 84, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 123, "license": { "key": "apache-2.0", "name": "Apache License 2.0", "spdx_id": "Apache-2.0", "url": "https://api.github.com/licenses/apache-2.0", "node_id": "MDc6TGljZW5zZTI=" }, "allow_forking": true, "is_template": false, "topics": ["ci", "devops", "docker", "hacktoberfest", "hacktoberfest2021", "woodpeckerci"], "visibility": "public", "forks": 84, "open_issues": 123, "watchers": 659, "default_branch": "main", "stargazers": 659, "main_branch": "main", "organization": "woodpecker-ci" }, "pusher": { "name": "6543", "email": "noreply@6543.de" }, "organization": { "login": "woodpecker-ci", "id": 84780935, "node_id": "MDEyOk9yZ2FuaXphdGlvbjg0NzgwOTM1", "url": "https://api.github.com/orgs/woodpecker-ci", "repos_url": "https://api.github.com/orgs/woodpecker-ci/repos", "events_url": "https://api.github.com/orgs/woodpecker-ci/events", "hooks_url": "https://api.github.com/orgs/woodpecker-ci/hooks", "issues_url": "https://api.github.com/orgs/woodpecker-ci/issues", "members_url": "https://api.github.com/orgs/woodpecker-ci/members{/member}", "public_members_url": "https://api.github.com/orgs/woodpecker-ci/public_members{/member}", "avatar_url": "https://avatars.githubusercontent.com/u/84780935?v=4", "description": "Woodpecker is a simple, yet powerful CI/CD engine with great extensibility." }, "sender": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "site_admin": false }, "created": false, "deleted": false, "forced": false, "base_ref": null, "compare": "https://github.com/woodpecker-ci/woodpecker/compare/2f780193b136...366701fde727", "commits": [ { "id": "366701fde727cb7a9e7f21eb88264f59f6f9b89c", "tree_id": "638e046f1e1e15dbed1ddf40f9471bf1af4d64ce", "distinct": true, "message": "Fix multiline secrets replacer (#700)\n\n* Fix multiline secrets replacer\r\n\r\n* Add tests", "timestamp": "2022-01-16T22:57:37+01:00", "url": "https://github.com/woodpecker-ci/woodpecker/commit/366701fde727cb7a9e7f21eb88264f59f6f9b89c", "author": { "name": "Philipp", "email": "noreply@philipp.xzy", "username": "nupplaphil" }, "committer": { "name": "GitHub", "email": "noreply@github.com", "username": "web-flow" }, "added": [], "removed": [], "modified": ["pipeline/shared/replace_secrets.go", "pipeline/shared/replace_secrets_test.go"] } ], "head_commit": { "id": "366701fde727cb7a9e7f21eb88264f59f6f9b89c", "tree_id": "638e046f1e1e15dbed1ddf40f9471bf1af4d64ce", "distinct": true, "message": "Fix multiline secrets replacer (#700)\n\n* Fix multiline secrets replacer\r\n\r\n* Add tests", "timestamp": "2022-01-16T22:57:37+01:00", "url": "https://github.com/woodpecker-ci/woodpecker/commit/366701fde727cb7a9e7f21eb88264f59f6f9b89c", "author": { "name": "Philipp", "email": "admin@philipp.info", "username": "nupplaphil" }, "committer": { "name": "GitHub", "email": "noreply@github.com", "username": "web-flow" }, "added": [], "removed": [], "modified": ["pipeline/shared/replace_secrets.go", "pipeline/shared/replace_secrets_test.go"] } } ================================================ FILE: server/forge/github/fixtures/HookRelease.json ================================================ { "action": "released", "release": { "url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases/2", "assets_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases/2/assets", "upload_url": "https://octocoders.github.io/api/uploads/repos/Codertocat/Hello-World/releases/2/assets{?name,label}", "html_url": "https://octocoders.github.io/Codertocat/Hello-World/releases/tag/0.0.1", "id": 2, "node_id": "MDc6UmVsZWFzZTI=", "tag_name": "0.0.1", "target_commitish": "master", "name": null, "draft": false, "author": { "login": "Codertocat", "id": 4, "node_id": "MDQ6VXNlcjQ=", "avatar_url": "https://octocoders.github.io/avatars/u/4?", "gravatar_id": "", "url": "https://octocoders.github.io/api/v3/users/Codertocat", "html_url": "https://octocoders.github.io/Codertocat", "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", "type": "User", "site_admin": false }, "prerelease": false, "created_at": "2019-05-15T19:37:08Z", "published_at": "2019-05-15T19:38:20Z", "assets": [], "tarball_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/tarball/0.0.1", "zipball_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/zipball/0.0.1", "body": null }, "repository": { "id": 118, "node_id": "MDEwOlJlcG9zaXRvcnkxMTg=", "name": "Hello-World", "full_name": "Codertocat/Hello-World", "private": false, "owner": { "login": "Codertocat", "id": 4, "node_id": "MDQ6VXNlcjQ=", "avatar_url": "https://octocoders.github.io/avatars/u/4?", "gravatar_id": "", "url": "https://octocoders.github.io/api/v3/users/Codertocat", "html_url": "https://octocoders.github.io/Codertocat", "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", "type": "User", "site_admin": false }, "html_url": "https://octocoders.github.io/Codertocat/Hello-World", "description": null, "fork": false, "url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World", "forks_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/forks", "keys_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/keys{/key_id}", "collaborators_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/collaborators{/collaborator}", "teams_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/teams", "hooks_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/hooks", "issue_events_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues/events{/number}", "events_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/events", "assignees_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/assignees{/user}", "branches_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/branches{/branch}", "tags_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/tags", "blobs_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/blobs{/sha}", "git_tags_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/tags{/sha}", "git_refs_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/refs{/sha}", "trees_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/trees{/sha}", "statuses_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/statuses/{sha}", "languages_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/languages", "stargazers_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/stargazers", "contributors_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/contributors", "subscribers_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/subscribers", "subscription_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/subscription", "commits_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/commits{/sha}", "git_commits_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/git/commits{/sha}", "comments_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/comments{/number}", "issue_comment_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues/comments{/number}", "contents_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/contents/{+path}", "compare_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/compare/{base}...{head}", "merges_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/merges", "archive_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/{archive_format}{/ref}", "downloads_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/downloads", "issues_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/issues{/number}", "pulls_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/pulls{/number}", "milestones_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/milestones{/number}", "notifications_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/notifications{?since,all,participating}", "labels_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/labels{/name}", "releases_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/releases{/id}", "deployments_url": "https://octocoders.github.io/api/v3/repos/Codertocat/Hello-World/deployments", "created_at": "2019-05-15T19:37:07Z", "updated_at": "2019-05-15T19:38:15Z", "pushed_at": "2019-05-15T19:38:19Z", "git_url": "git://octocoders.github.io/Codertocat/Hello-World.git", "ssh_url": "git@octocoders.github.io:Codertocat/Hello-World.git", "clone_url": "https://octocoders.github.io/Codertocat/Hello-World.git", "svn_url": "https://octocoders.github.io/Codertocat/Hello-World", "homepage": null, "size": 0, "stargazers_count": 0, "watchers_count": 0, "language": "Ruby", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": true, "forks_count": 1, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 2, "license": null, "forks": 1, "open_issues": 2, "watchers": 0, "default_branch": "master" }, "enterprise": { "id": 1, "slug": "github", "name": "GitHub", "node_id": "MDg6QnVzaW5lc3Mx", "avatar_url": "https://octocoders.github.io/avatars/b/1?", "description": null, "website_url": null, "html_url": "https://octocoders.github.io/businesses/github", "created_at": "2019-05-14T19:31:12Z", "updated_at": "2019-05-14T19:31:12Z" }, "sender": { "login": "Codertocat", "id": 4, "node_id": "MDQ6VXNlcjQ=", "avatar_url": "https://octocoders.github.io/avatars/u/4?", "gravatar_id": "", "url": "https://octocoders.github.io/api/v3/users/Codertocat", "html_url": "https://octocoders.github.io/Codertocat", "followers_url": "https://octocoders.github.io/api/v3/users/Codertocat/followers", "following_url": "https://octocoders.github.io/api/v3/users/Codertocat/following{/other_user}", "gists_url": "https://octocoders.github.io/api/v3/users/Codertocat/gists{/gist_id}", "starred_url": "https://octocoders.github.io/api/v3/users/Codertocat/starred{/owner}{/repo}", "subscriptions_url": "https://octocoders.github.io/api/v3/users/Codertocat/subscriptions", "organizations_url": "https://octocoders.github.io/api/v3/users/Codertocat/orgs", "repos_url": "https://octocoders.github.io/api/v3/users/Codertocat/repos", "events_url": "https://octocoders.github.io/api/v3/users/Codertocat/events{/privacy}", "received_events_url": "https://octocoders.github.io/api/v3/users/Codertocat/received_events", "type": "User", "site_admin": false }, "installation": { "id": 5, "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNQ==" } } ================================================ FILE: server/forge/github/fixtures/HookTag.json ================================================ { "ref": "refs/tags/the-tag-v1", "before": "0000000000000000000000000000000000000000", "after": "67012991d6c69b1c58378346fca366b864d8d1a1", "repository": { "id": 1028609128, "node_id": "R_kgDOPU9UaA", "name": "test_ci_tmp", "full_name": "6543/test_ci_tmp", "private": false, "owner": { "name": "6543", "email": "6543@obermui.de", "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "html_url": "https://github.com/6543/test_ci_tmp", "description": null, "fork": false, "url": "https://api.github.com/repos/6543/test_ci_tmp", "forks_url": "https://api.github.com/repos/6543/test_ci_tmp/forks", "keys_url": "https://api.github.com/repos/6543/test_ci_tmp/keys{/key_id}", "collaborators_url": "https://api.github.com/repos/6543/test_ci_tmp/collaborators{/collaborator}", "teams_url": "https://api.github.com/repos/6543/test_ci_tmp/teams", "hooks_url": "https://api.github.com/repos/6543/test_ci_tmp/hooks", "issue_events_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/events{/number}", "events_url": "https://api.github.com/repos/6543/test_ci_tmp/events", "assignees_url": "https://api.github.com/repos/6543/test_ci_tmp/assignees{/user}", "branches_url": "https://api.github.com/repos/6543/test_ci_tmp/branches{/branch}", "tags_url": "https://api.github.com/repos/6543/test_ci_tmp/tags", "blobs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/blobs{/sha}", "git_tags_url": "https://api.github.com/repos/6543/test_ci_tmp/git/tags{/sha}", "git_refs_url": "https://api.github.com/repos/6543/test_ci_tmp/git/refs{/sha}", "trees_url": "https://api.github.com/repos/6543/test_ci_tmp/git/trees{/sha}", "statuses_url": "https://api.github.com/repos/6543/test_ci_tmp/statuses/{sha}", "languages_url": "https://api.github.com/repos/6543/test_ci_tmp/languages", "stargazers_url": "https://api.github.com/repos/6543/test_ci_tmp/stargazers", "contributors_url": "https://api.github.com/repos/6543/test_ci_tmp/contributors", "subscribers_url": "https://api.github.com/repos/6543/test_ci_tmp/subscribers", "subscription_url": "https://api.github.com/repos/6543/test_ci_tmp/subscription", "commits_url": "https://api.github.com/repos/6543/test_ci_tmp/commits{/sha}", "git_commits_url": "https://api.github.com/repos/6543/test_ci_tmp/git/commits{/sha}", "comments_url": "https://api.github.com/repos/6543/test_ci_tmp/comments{/number}", "issue_comment_url": "https://api.github.com/repos/6543/test_ci_tmp/issues/comments{/number}", "contents_url": "https://api.github.com/repos/6543/test_ci_tmp/contents/{+path}", "compare_url": "https://api.github.com/repos/6543/test_ci_tmp/compare/{base}...{head}", "merges_url": "https://api.github.com/repos/6543/test_ci_tmp/merges", "archive_url": "https://api.github.com/repos/6543/test_ci_tmp/{archive_format}{/ref}", "downloads_url": "https://api.github.com/repos/6543/test_ci_tmp/downloads", "issues_url": "https://api.github.com/repos/6543/test_ci_tmp/issues{/number}", "pulls_url": "https://api.github.com/repos/6543/test_ci_tmp/pulls{/number}", "milestones_url": "https://api.github.com/repos/6543/test_ci_tmp/milestones{/number}", "notifications_url": "https://api.github.com/repos/6543/test_ci_tmp/notifications{?since,all,participating}", "labels_url": "https://api.github.com/repos/6543/test_ci_tmp/labels{/name}", "releases_url": "https://api.github.com/repos/6543/test_ci_tmp/releases{/id}", "deployments_url": "https://api.github.com/repos/6543/test_ci_tmp/deployments", "created_at": 1753817741, "updated_at": "2025-07-29T19:36:23Z", "pushed_at": 1760097372, "git_url": "git://github.com/6543/test_ci_tmp.git", "ssh_url": "git@github.com:6543/test_ci_tmp.git", "clone_url": "https://github.com/6543/test_ci_tmp.git", "svn_url": "https://github.com/6543/test_ci_tmp", "homepage": null, "size": 3, "stargazers_count": 0, "watchers_count": 0, "language": "Dockerfile", "has_issues": true, "has_projects": true, "has_downloads": true, "has_wiki": true, "has_pages": false, "has_discussions": false, "forks_count": 0, "mirror_url": null, "archived": false, "disabled": false, "open_issues_count": 0, "license": null, "allow_forking": true, "is_template": false, "web_commit_signoff_required": false, "topics": [], "visibility": "public", "forks": 0, "open_issues": 0, "watchers": 0, "default_branch": "main", "stargazers": 0, "master_branch": "main" }, "pusher": { "name": "6543", "email": "6543@obermui.de" }, "sender": { "login": "6543", "id": 24977596, "node_id": "MDQ6VXNlcjI0OTc3NTk2", "avatar_url": "https://avatars.githubusercontent.com/u/24977596?v=4", "gravatar_id": "", "url": "https://api.github.com/users/6543", "html_url": "https://github.com/6543", "followers_url": "https://api.github.com/users/6543/followers", "following_url": "https://api.github.com/users/6543/following{/other_user}", "gists_url": "https://api.github.com/users/6543/gists{/gist_id}", "starred_url": "https://api.github.com/users/6543/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/6543/subscriptions", "organizations_url": "https://api.github.com/users/6543/orgs", "repos_url": "https://api.github.com/users/6543/repos", "events_url": "https://api.github.com/users/6543/events{/privacy}", "received_events_url": "https://api.github.com/users/6543/received_events", "type": "User", "user_view_type": "public", "site_admin": false }, "created": true, "deleted": false, "forced": false, "base_ref": "refs/heads/main", "compare": "https://github.com/6543/test_ci_tmp/compare/the-tag-v1", "commits": [], "head_commit": { "id": "67012991d6c69b1c58378346fca366b864d8d1a1", "tree_id": "8fb363ff1374abf0b7f3598e28a15ebdc443cb02", "distinct": true, "message": "Update .woodpecker.yml", "timestamp": "2025-07-29T16:41:24+02:00", "url": "https://github.com/6543/test_ci_tmp/commit/67012991d6c69b1c58378346fca366b864d8d1a1", "author": { "name": "6543", "email": "6543@obermui.de", "username": "6543" }, "committer": { "name": "6543", "email": "6543@obermui.de", "username": "6543" }, "added": [], "removed": [], "modified": [".woodpecker.yml"] } } ================================================ FILE: server/forge/github/fixtures/handler.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( "net/http" "github.com/gin-gonic/gin" ) // Handler returns an http.Handler that is capable of handling a variety of mock // Bitbucket requests and returning mock responses. func Handler() http.Handler { gin.SetMode(gin.TestMode) e := gin.New() e.GET("/api/v3/repos/:owner/:name", getRepo) e.GET("/api/v3/repositories/:id", getRepoByID) e.GET("/api/v3/orgs/:org/memberships/:user", getMembership) e.GET("/api/v3/user/memberships/orgs/:org", getMembership) return e } func getRepo(c *gin.Context) { switch c.Param("name") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoPayload) } } func getRepoByID(c *gin.Context) { switch c.Param("id") { case "repo_not_found": c.String(http.StatusNotFound, "") default: c.String(http.StatusOK, repoPayload) } } func getMembership(c *gin.Context) { switch c.Param("org") { case "org_not_found": c.String(http.StatusNotFound, "") case "github": c.String(http.StatusOK, membershipIsMemberPayload) default: c.String(http.StatusOK, membershipIsOwnerPayload) } } var repoPayload = ` { "id": 5, "owner": { "login": "octocat", "avatar_url": "https://github.com/images/error/octocat_happy.gif" }, "name": "Hello-World", "full_name": "octocat/Hello-World", "private": true, "html_url": "https://github.com/octocat/Hello-World", "clone_url": "https://github.com/octocat/Hello-World.git", "language": null, "permissions": { "admin": true, "push": true, "pull": true } } ` var membershipIsOwnerPayload = ` { "url": "https://api.github.com/orgs/octocat/memberships/octocat", "state": "active", "role": "admin", "organization_url": "https://api.github.com/orgs/octocat", "user": { "login": "octocat", "id": 5555555, "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "organization": { "login": "octocat", "id": 5555556, "url": "https://api.github.com/orgs/octocat", "repos_url": "https://api.github.com/orgs/octocat/repos", "events_url": "https://api.github.com/orgs/octocat/events", "hooks_url": "https://api.github.com/orgs/octocat/hooks", "issues_url": "https://api.github.com/orgs/octocat/issues", "members_url": "https://api.github.com/orgs/octocat/members{/member}", "public_members_url": "https://api.github.com/orgs/octocat/public_members{/member}", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "description": "" } } ` var membershipIsMemberPayload = ` { "url": "https://api.github.com/orgs/github/memberships/octocat", "state": "active", "role": "member", "organization_url": "https://api.github.com/orgs/github", "user": { "login": "octocat", "id": 5555555, "avatar_url": "https://github.com/images/error/octocat_happy.gif", "gravatar_id": "", "url": "https://api.github.com/users/octocat", "html_url": "https://github.com/octocat", "followers_url": "https://api.github.com/users/octocat/followers", "following_url": "https://api.github.com/users/octocat/following{/other_user}", "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", "organizations_url": "https://api.github.com/users/octocat/orgs", "repos_url": "https://api.github.com/users/octocat/repos", "events_url": "https://api.github.com/users/octocat/events{/privacy}", "received_events_url": "https://api.github.com/users/octocat/received_events", "type": "User", "site_admin": false }, "organization": { "login": "octocat", "id": 5555557, "url": "https://api.github.com/orgs/github", "repos_url": "https://api.github.com/orgs/github/repos", "events_url": "https://api.github.com/orgs/github/events", "hooks_url": "https://api.github.com/orgs/github/hooks", "issues_url": "https://api.github.com/orgs/github/issues", "members_url": "https://api.github.com/orgs/github/members{/member}", "public_members_url": "https://api.github.com/orgs/github/public_members{/member}", "avatar_url": "https://github.com/images/error/octocat_happy.gif", "description": "" } } ` ================================================ FILE: server/forge/github/fixtures/hooks.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import _ "embed" // HookPush is a sample push hook. // https://developer.github.com/v3/activity/events/types/#pushevent // //go:embed HookPush.json var HookPush string // HookPushDeleted is a sample push hook that is marked as deleted, and is expected to be ignored. const HookPushDeleted = ` { "deleted": true } ` // HookPullRequest is a sample hook pull request // https://developer.github.com/v3/activity/events/types/#pullrequestevent // //go:embed HookPullRequest.json var HookPullRequest string // HookPullRequestInvalidAction is a sample hook pull request that has an // action not equal to synchronize or opened, and is expected to be ignored. const HookPullRequestInvalidAction = ` { "action": "reopened", "number": 1 } ` // HookPullRequestInvalidState is a sample hook pull request that has a state // not equal to open, and is expected to be ignored. const HookPullRequestInvalidState = ` { "action": "synchronize", "pull_request": { "number": 1, "state": "closed" } } ` // HookPush is a sample deployment hook. // https://developer.github.com/v3/activity/events/types/#deploymentevent // //go:embed HookDeploy.json var HookDeploy string //go:embed HookPullRequestMerged.json var HookPullRequestMerged string // HookPullRequest is a sample hook pull request // https://developer.github.com/v3/activity/events/types/#pullrequestevent // //go:embed HookPullRequestClosed.json var HookPullRequestClosed string //go:embed HookPullRequestEdited.json var HookPullRequestEdited string //go:embed HookRelease.json var HookRelease string //go:embed HookTag.json var HookTag string //go:embed HookPullRequestReviewRequested.json var HookPullRequestReviewRequested string //go:embed HookPullRequestMilestoneAdded.json var HookPullRequestMilestoneAdded string //go:embed HookPullRequestMilestoneRemoved.json var HookPullRequestMilestoneRemoved string //go:embed HookPullRequestLabelAdded.json var HookPullRequestLabelAdded string //go:embed HookPullRequestLabelRemoved.json var HookPullRequestLabelRemoved string //go:embed HookPullRequestAssigneeAdded.json var HookPullRequestAssigneeAdded string //go:embed HookPullRequestAssigneeRemoved.json var HookPullRequestAssigneeRemoved string //go:embed HookPullRequestReopened.json var HookPullRequestReopened string //go:embed HookPullRequestLabelsCleared.json var HookPullRequestLabelsCleared string ================================================ FILE: server/forge/github/fixtures/mock_server.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures ================================================ FILE: server/forge/github/github.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package github import ( "context" "crypto/tls" "errors" "fmt" "net/http" "net/url" "regexp" "strconv" "strings" "time" "github.com/google/go-github/v86/github" "github.com/rs/zerolog/log" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) type contextKey string const ( defaultURL = "https://github.com" // Default GitHub URL defaultAPI = "https://api.github.com/" // Default GitHub API URL defaultPageSize = 100 githubClientKey contextKey = "github_client" ) // Opts defines configuration options. type Opts struct { URL string // GitHub server url. OAuthClientID string // GitHub oauth client id. OAuthClientSecret string // GitHub oauth client secret. SkipVerify bool // Skip ssl verification. MergeRef bool // Clone pull requests using the merge ref. OnlyPublic bool // Only obtain OAuth tokens with access to public repos. OAuthHost string // Public url for oauth if different from url. } // New returns a Forge implementation that integrates with a GitHub Cloud or // GitHub Enterprise version control hosting provider. func New(id int64, opts Opts) (forge.Forge, error) { r := &client{ id: id, API: defaultAPI, url: defaultURL, Client: opts.OAuthClientID, Secret: opts.OAuthClientSecret, oAuthHost: opts.OAuthHost, SkipVerify: opts.SkipVerify, MergeRef: opts.MergeRef, OnlyPublic: opts.OnlyPublic, } if opts.URL != defaultURL { r.url = strings.TrimSuffix(opts.URL, "/") r.API = r.url + "/api/v3/" } return r, nil } type client struct { id int64 url string API string Client string Secret string SkipVerify bool MergeRef bool OnlyPublic bool oAuthHost string } // Name returns the string name of this driver. func (c *client) Name() string { return "github" } // URL returns the root url of a configured forge. func (c *client) URL() string { return c.url } // Login authenticates the session and returns the forge user details. func (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { config := c.newConfig() redirectURL := config.AuthCodeURL(req.State) // check the OAuth code if len(req.Code) == 0 { // TODO(bradrydzewski) we really should be using a random value here and // storing in a cookie for verification in the next stage of the workflow. return nil, redirectURL, nil } token, err := config.Exchange(c.newContext(ctx), req.Code) if err != nil { return nil, redirectURL, err } client := c.newClientToken(ctx, token.AccessToken) user, _, err := client.Users.Get(ctx, "") if err != nil { return nil, redirectURL, err } emails, _, err := client.Users.ListEmails(ctx, nil) if err != nil { return nil, redirectURL, err } email := matchingEmail(emails, c.API) if email == nil { return nil, redirectURL, fmt.Errorf("no verified Email address for GitHub account") } return &model.User{ Login: user.GetLogin(), Email: email.GetEmail(), AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry.UTC().Unix(), Avatar: user.GetAvatarURL(), ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(user.GetID())), }, redirectURL, nil } // Refresh refreshes the Gitlab oauth2 access token. If the token is // refreshed the user is updated and a true value is returned. func (c *client) Refresh(ctx context.Context, user *model.User) (bool, error) { // when using Github oAuth app no refresh token is provided if user.RefreshToken == "" { return false, nil } config := c.newConfig() source := config.TokenSource(ctx, &oauth2.Token{ AccessToken: user.AccessToken, RefreshToken: user.RefreshToken, Expiry: time.Unix(user.Expiry, 0), }) token, err := source.Token() if err != nil || len(token.AccessToken) == 0 { return false, err } user.AccessToken = token.AccessToken user.RefreshToken = token.RefreshToken user.Expiry = token.Expiry.UTC().Unix() return true, nil } // Teams returns a list of all team membership for the GitHub account. func (c *client) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { client := c.newClientToken(ctx, u.AccessToken) list, _, err := client.Organizations.List(ctx, "", &github.ListOptions{ Page: p.Page, PerPage: perPage(p.PerPage), }) if err != nil { return nil, err } return convertTeamList(list), nil } // Repo returns the GitHub repository. func (c *client) Repo(ctx context.Context, u *model.User, id model.ForgeRemoteID, owner, name string) (*model.Repo, error) { client := c.newClientToken(ctx, u.AccessToken) if id.IsValid() { intID, err := strconv.ParseInt(string(id), 10, 64) if err != nil { return nil, err } repo, resp, err := client.Repositories.GetByID(ctx, intID) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return nil, err } return convertRepo(repo), nil } repo, resp, err := client.Repositories.Get(ctx, owner, name) if err != nil { if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return nil, err } return convertRepo(repo), nil } // Repos returns a list of all repositories for GitHub account, including // organization repositories. func (c *client) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { // we paginate internally (https://github.com/woodpecker-ci/woodpecker/issues/5667) if p.Page != 1 { return nil, nil } client := c.newClientToken(ctx, u.AccessToken) opts := new(github.RepositoryListByAuthenticatedUserOptions) opts.PerPage = 100 opts.Page = 1 var repos []*model.Repo for opts.Page > 0 { list, resp, err := client.Repositories.ListByAuthenticatedUser(ctx, opts) if err != nil { return nil, err } for _, repo := range list { if repo.GetArchived() { continue } repos = append(repos, convertRepo(repo)) } opts.Page = resp.NextPage } return repos, nil } // File fetches the file from the GitHub repository and returns its contents. func (c *client) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error) { client := c.newClientToken(ctx, u.AccessToken) opts := new(github.RepositoryContentGetOptions) opts.Ref = b.Commit content, _, resp, err := client.Repositories.GetContents(ctx, r.Owner, r.Name, f, opts) if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}}) } if err != nil { return nil, err } if content == nil { return nil, fmt.Errorf("%s is a folder not a file use Dir(..)", f) } data, err := content.GetContent() return []byte(data), err } func (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]*forge_types.FileMeta, error) { client := c.newClientToken(ctx, u.AccessToken) opts := new(github.RepositoryContentGetOptions) opts.Ref = b.Commit _, data, resp, err := client.Repositories.GetContents(ctx, r.Owner, r.Name, f, opts) if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{f}}) } if err != nil { return nil, err } fc := make(chan *forge_types.FileMeta) errChan := make(chan error) for _, file := range data { go func(path string) { content, err := c.File(ctx, u, r, b, path) if err != nil { if errors.Is(err, &forge_types.ErrConfigNotFound{}) { err = fmt.Errorf("git tree reported existence of file but we got: %s", err.Error()) } errChan <- err } else { fc <- &forge_types.FileMeta{ Name: path, Data: content, } } }(f + "/" + *file.Name) } var files []*forge_types.FileMeta for range data { select { case err := <-errChan: return nil, err case fileMeta := <-fc: files = append(files, fileMeta) } } close(fc) close(errChan) return files, nil } func (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { token := common.UserToken(ctx, r, u) client := c.newClientToken(ctx, token) pullRequests, _, err := client.PullRequests.List(ctx, r.Owner, r.Name, &github.PullRequestListOptions{ ListOptions: github.ListOptions{ Page: p.Page, PerPage: perPage(p.PerPage), }, State: "open", }) if err != nil { return nil, err } result := make([]*model.PullRequest, len(pullRequests)) for i := range pullRequests { result[i] = &model.PullRequest{ Index: model.ForgeRemoteID(strconv.Itoa(pullRequests[i].GetNumber())), Title: pullRequests[i].GetTitle(), } } return result, err } // Netrc returns a netrc file capable of authenticating GitHub requests and // cloning GitHub repositories. The netrc will use the global machine account // when configured. func (c *client) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { login := "" token := "" if u != nil { login = u.AccessToken token = "x-oauth-basic" } host, err := common.ExtractHostFromCloneURL(r.Clone) if err != nil { return nil, err } return &model.Netrc{ Login: login, Password: token, Machine: host, Type: model.ForgeTypeGithub, }, nil } // Deactivate deactivates the repository be removing registered push hooks from // the GitHub repository. func (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { client := c.newClientToken(ctx, u.AccessToken) // make sure a repo rename does not trick us forgeRepo, err := c.Repo(ctx, u, r.ForgeRemoteID, r.Owner, r.Name) if err != nil { return err } hooks, _, err := client.Repositories.ListHooks(ctx, forgeRepo.Owner, forgeRepo.Name, nil) if err != nil { return err } match := matchingHooks(hooks, link) if match == nil { return nil } _, err = client.Repositories.DeleteHook(ctx, forgeRepo.Owner, forgeRepo.Name, *match.ID) return err } // OrgMembership returns if user is member of organization and if user // is admin/owner in this organization. func (c *client) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) { client := c.newClientToken(ctx, u.AccessToken) org, _, err := client.Organizations.GetOrgMembership(ctx, u.Login, owner) if err != nil { return nil, err } return &model.OrgPerm{Member: org.GetState() == "active", Admin: org.GetRole() == "admin"}, nil } func (c *client) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) { client := c.newClientToken(ctx, u.AccessToken) org, _, err := client.Organizations.Get(ctx, owner) log.Trace().Msgf("GitHub organization for owner %s = %v", owner, org) if org != nil && err == nil { return &model.Org{ Name: org.GetLogin(), IsUser: false, }, nil } user, _, err := client.Users.Get(ctx, owner) log.Trace().Msgf("GitHub user for owner %s = %v", owner, user) if err != nil { return nil, err } return &model.Org{ Name: user.GetLogin(), IsUser: true, }, nil } // newContext returns the GitHub oauth2 context using an HTTPClient that // disables TLS verification if disabled in the forge settings. func (c *client) newContext(ctx context.Context) context.Context { if !c.SkipVerify { return ctx } return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ Transport: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, }, }) } // newConfig returns the GitHub oauth2 config. func (c *client) newConfig() *oauth2.Config { scopes := []string{"user:email", "read:org"} if c.OnlyPublic { scopes = append(scopes, []string{"admin:repo_hook", "repo:status"}...) } else { scopes = append(scopes, "repo") } publicOAuthURL := c.oAuthHost if publicOAuthURL == "" { publicOAuthURL = c.url } return &oauth2.Config{ ClientID: c.Client, ClientSecret: c.Secret, Scopes: scopes, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("%s/login/oauth/authorize", publicOAuthURL), TokenURL: fmt.Sprintf("%s/login/oauth/access_token", c.url), }, RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), } } // newClientToken returns the GitHub oauth2 client. // It first checks if a client is available in the context, otherwise creates a new one. func (c *client) newClientToken(ctx context.Context, token string) *github.Client { // Check if a client is already in the context if ctxClient, ok := ctx.Value(githubClientKey).(*github.Client); ok { return ctxClient } ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, ) tc := oauth2.NewClient(ctx, ts) // Get the oauth2 transport to set custom base tp, _ := tc.Transport.(*oauth2.Transport) baseTransport := &http.Transport{ Proxy: http.ProxyFromEnvironment, } if c.SkipVerify { baseTransport.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } } // Wrap the base transport with User-Agent support tp.Base = httputil.NewUserAgentRoundTripper(baseTransport, "forge-github") client := github.NewClient(tc) client.BaseURL, _ = url.Parse(c.API) return client } // matchingEmail returns matching user email. func matchingEmail(emails []*github.UserEmail, rawURL string) *github.UserEmail { for _, email := range emails { if email.Email == nil || email.Primary == nil || email.Verified == nil { continue } if *email.Primary && *email.Verified { return email } } // github enterprise does not support verified email addresses so instead // we'll return the first email address in the list. if len(emails) != 0 && rawURL != defaultAPI { return emails[0] } return nil } // matchingHooks returns matching hook. func matchingHooks(hooks []*github.Hook, rawURL string) *github.Hook { link, err := url.Parse(rawURL) if err != nil { return nil } for _, hook := range hooks { if hook.ID == nil { continue } hookURL, err := url.Parse(hook.Config.GetURL()) if err == nil && hookURL.Host == link.Host { return hook } } return nil } var reDeploy = regexp.MustCompile(`.+/deployments/(\d+)`) // Status sends the commit status to the forge. // An example would be the GitHub pull request status. func (c *client) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { client := c.newClientToken(ctx, user.AccessToken) if pipeline.Event == model.EventDeploy { // Get id from url. If not found, skip. matches := reDeploy.FindStringSubmatch(pipeline.ForgeURL) //nolint:mnd if len(matches) != 2 { return nil } id, _ := strconv.Atoi(matches[1]) _, _, err := client.Repositories.CreateDeploymentStatus(ctx, repo.Owner, repo.Name, int64(id), &github.DeploymentStatusRequest{ State: github.Ptr(convertStatus(pipeline.Status)), Description: github.Ptr(common.GetPipelineStatusDescription(pipeline.Status)), LogURL: github.Ptr(common.GetPipelineStatusURL(repo, pipeline, nil)), }) return err } _, _, err := client.Repositories.CreateStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, github.RepoStatus{ Context: github.Ptr(common.GetPipelineStatusContext(repo, pipeline, workflow)), State: github.Ptr(convertStatus(workflow.State)), Description: github.Ptr(common.GetPipelineStatusDescription(workflow.State)), TargetURL: github.Ptr(common.GetPipelineStatusURL(repo, pipeline, workflow)), }) return err } // Activate activates a repository by creating the post-commit hook and // adding the SSH deploy key, if applicable. func (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { if err := c.Deactivate(ctx, u, r, link); err != nil { return err } client := c.newClientToken(ctx, u.AccessToken) hook := &github.Hook{ Name: github.Ptr("web"), Events: []string{ "push", "pull_request", "pull_request_review", "deployment", }, Config: &github.HookConfig{ URL: &link, ContentType: github.Ptr("form"), }, } _, _, err := client.Repositories.CreateHook(ctx, r.Owner, r.Name, hook) return err } // Branches returns the names of all branches for the named repository. func (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { token := common.UserToken(ctx, r, u) client := c.newClientToken(ctx, token) githubBranches, _, err := client.Repositories.ListBranches(ctx, r.Owner, r.Name, &github.BranchListOptions{ ListOptions: github.ListOptions{ Page: p.Page, PerPage: perPage(p.PerPage), }, }) if err != nil { return nil, err } branches := make([]string, 0) for _, branch := range githubBranches { branches = append(branches, *branch.Name) } return branches, nil } // BranchHead returns the sha of the head (latest commit) of the specified branch. func (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { token := common.UserToken(ctx, r, u) b, _, err := c.newClientToken(ctx, token).Repositories.GetBranch(ctx, r.Owner, r.Name, branch, 1) if err != nil { return nil, err } return &model.Commit{ SHA: b.GetCommit().GetSHA(), ForgeURL: b.GetCommit().GetHTMLURL(), }, nil } // Hook parses the post-commit hook from the Request body // and returns the required data in a standard format. func (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { pull, repo, pipeline, currCommit, prevCommit, err := parseHook(r, c.MergeRef) if err != nil { return nil, nil, err } if pipeline != nil && pipeline.Event == model.EventRelease && pipeline.Commit == "" { tagName := strings.Split(pipeline.Ref, "/")[2] sha, err := c.getTagCommitSHA(ctx, repo, tagName) if err != nil { return nil, nil, err } pipeline.Commit = sha } if pull != nil { pipeline, err = c.loadChangedFilesFromPullRequest(ctx, pull, repo, pipeline) if err != nil { return nil, nil, err } } else if pipeline != nil && pipeline.Event == model.EventPush { // GitHub has removed commit summaries from Events API payloads from 7th October 2025 onwards. pipeline, err = c.loadChangedFilesFromCommits(ctx, repo, pipeline, currCommit, prevCommit) if err != nil { return nil, nil, err } } return repo, pipeline, nil } func (c *client) loadChangedFilesFromPullRequest(ctx context.Context, pull *github.PullRequest, tmpRepo *model.Repo, pipeline *model.Pipeline) (*model.Pipeline, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return pipeline, nil } repo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName) if err != nil { return nil, err } user, err := _store.GetUser(repo.UserID) if err != nil { return nil, err } // Refresh the OAuth token before making API calls. // The token may be expired, and without this refresh the API calls below // would fail with an authentication error. forge.Refresh(ctx, c, _store, user) gh := c.newClientToken(ctx, user.AccessToken) fileList := make([]string, 0, 16) opts := &github.ListOptions{Page: 1} for opts.Page > 0 { files, resp, err := gh.PullRequests.ListFiles(ctx, repo.Owner, repo.Name, pull.GetNumber(), opts) if err != nil { return nil, err } for _, file := range files { fileList = append(fileList, file.GetFilename(), file.GetPreviousFilename()) } opts.Page = resp.NextPage } pipeline.ChangedFiles = utils.DeduplicateStrings(fileList) return pipeline, err } func (c *client) getTagCommitSHA(ctx context.Context, repo *model.Repo, tagName string) (string, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return "", nil } repo, err := _store.GetRepoNameFallback(c.id, repo.ForgeRemoteID, repo.FullName) if err != nil { return "", err } user, err := _store.GetUser(repo.UserID) if err != nil { return "", err } // Refresh the OAuth token before making API calls. // The token may be expired, and without this refresh the API calls below // would fail with an authentication error. forge.Refresh(ctx, c, _store, user) gh := c.newClientToken(ctx, user.AccessToken) page := 1 var tag *github.RepositoryTag for { tags, _, err := gh.Repositories.ListTags(ctx, repo.Owner, repo.Name, &github.ListOptions{Page: page}) if err != nil { return "", err } for _, t := range tags { if t.GetName() == tagName { tag = t break } } if tag != nil { break } } if tag == nil { return "", fmt.Errorf("could not find tag %s", tagName) } return tag.GetCommit().GetSHA(), nil } func (c *client) loadChangedFilesFromCommits(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, curr, prev string) (*model.Pipeline, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return pipeline, nil } switch prev { case curr: log.Error().Msg("GitHub push event contains the same commit before and after, no changes detected") return pipeline, nil case "0000000000000000000000000000000000000000": prev = "" fallthrough case "": // For tag events, prev is empty, but we can still fetch the changed files using the current commit log.Trace().Msg("GitHub tag event, fetching changed files using current commit") } repo, err := _store.GetRepoNameFallback(c.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName) if err != nil { return nil, err } user, err := _store.GetUser(repo.UserID) if err != nil { return nil, err } // Refresh the OAuth token before making API calls. // The token may be expired, and without this refresh the API calls below // would fail with an authentication error. forge.Refresh(ctx, c, _store, user) gh := c.newClientToken(ctx, user.AccessToken) fileList := make([]string, 0, 16) if prev == "" { opts := &github.ListOptions{Page: 1} for opts.Page > 0 { commit, resp, err := gh.Repositories.GetCommit(ctx, repo.Owner, repo.Name, curr, opts) if err != nil { return nil, err } for _, file := range commit.Files { fileList = append(fileList, file.GetFilename(), file.GetPreviousFilename()) } opts.Page = resp.NextPage } } else { opts := &github.ListOptions{Page: 1} for opts.Page > 0 { comp, resp, err := gh.Repositories.CompareCommits(ctx, repo.Owner, repo.Name, prev, curr, opts) if err != nil { return nil, err } for _, file := range comp.Files { fileList = append(fileList, file.GetFilename(), file.GetPreviousFilename()) } opts.Page = resp.NextPage } } pipeline.ChangedFiles = utils.DeduplicateStrings(fileList) return pipeline, err } func perPage(custom int) int { if custom < 1 || custom > defaultPageSize { return defaultPageSize } return custom } ================================================ FILE: server/forge/github/github_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package github import ( "context" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/google/go-github/v86/github" github_mock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge/github/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestNew(t *testing.T) { forge, _ := New(1, Opts{ URL: "http://localhost:8080/", OAuthClientID: "0ZXh0IjoiI", OAuthClientSecret: "I1NiIsInR5", SkipVerify: true, }) f, _ := forge.(*client) assert.Equal(t, "http://localhost:8080", f.url) assert.Equal(t, "http://localhost:8080/api/v3/", f.API) assert.Equal(t, "0ZXh0IjoiI", f.Client) assert.Equal(t, "I1NiIsInR5", f.Secret) assert.True(t, f.SkipVerify) } func Test_github(t *testing.T) { gin.SetMode(gin.TestMode) s := httptest.NewServer(fixtures.Handler()) c, _ := New(1, Opts{ URL: s.URL, SkipVerify: true, }) defer s.Close() ctx := t.Context() t.Run("netrc with user token", func(t *testing.T) { forge, _ := New(1, Opts{}) netrc, _ := forge.Netrc(fakeUser, fakeRepo) assert.Equal(t, "github.com", netrc.Machine) assert.Equal(t, fakeUser.AccessToken, netrc.Login) assert.Equal(t, "x-oauth-basic", netrc.Password) assert.Equal(t, model.ForgeTypeGithub, netrc.Type) }) t.Run("netrc with machine account", func(t *testing.T) { forge, _ := New(1, Opts{}) netrc, _ := forge.Netrc(nil, fakeRepo) assert.Equal(t, "github.com", netrc.Machine) assert.Empty(t, netrc.Login) assert.Empty(t, netrc.Password) }) t.Run("Should return the repository details", func(t *testing.T) { repo, err := c.Repo(ctx, fakeUser, fakeRepo.ForgeRemoteID, fakeRepo.Owner, fakeRepo.Name) assert.NoError(t, err) assert.Equal(t, fakeRepo.ForgeRemoteID, repo.ForgeRemoteID) assert.Equal(t, fakeRepo.Owner, repo.Owner) assert.Equal(t, fakeRepo.Name, repo.Name) assert.Equal(t, fakeRepo.FullName, repo.FullName) assert.True(t, repo.IsSCMPrivate) assert.Equal(t, fakeRepo.Clone, repo.Clone) assert.Equal(t, fakeRepo.ForgeURL, repo.ForgeURL) }) t.Run("repo not found error", func(t *testing.T) { _, err := c.Repo(ctx, fakeUser, "0", fakeRepoNotFound.Owner, fakeRepoNotFound.Name) assert.Error(t, err) }) } var ( fakeUser = &model.User{ Login: "6543", AccessToken: "cfcd2084", } fakeRepo = &model.Repo{ ForgeRemoteID: "5", Owner: "octocat", Name: "Hello-World", FullName: "octocat/Hello-World", Avatar: "https://github.com/images/error/octocat_happy.gif", ForgeURL: "https://github.com/octocat/Hello-World", Clone: "https://github.com/octocat/Hello-World.git", IsSCMPrivate: true, } fakeRepoNotFound = &model.Repo{ Owner: "test_name", Name: "repo_not_found", FullName: "test_name/repo_not_found", } ) func TestHook(t *testing.T) { // Mock GitHub API for changed files mockedHTTPClient := github_mock.NewMockedHTTPClient( github_mock.WithRequestMatch( github_mock.GetReposCommitsByOwnerByRepoByRef, github.RepositoryCommit{ Files: []*github.CommitFile{ {Filename: github.Ptr("README.md")}, {Filename: github.Ptr("main.go")}, }, }, ), github_mock.WithRequestMatch( github_mock.GetReposCompareByOwnerByRepoByBasehead, github.CommitsComparison{ Files: []*github.CommitFile{ {Filename: github.Ptr("main.go")}, }, }, ), github_mock.WithRequestMatch( github_mock.GetReposPullsFilesByOwnerByRepoByPullNumber, []*github.CommitFile{ {Filename: github.Ptr("README.md")}, {Filename: github.Ptr("main.go")}, }, ), ) // Create a GitHub client with the mocked HTTP client gh := github.NewClient(mockedHTTPClient) // Use the custom type as the key ctx := context.WithValue(context.Background(), githubClientKey, gh) // Create a mock store using the proper mocking pattern mockStore := store_mocks.NewMockStore(t) mockStore.On("GetUser", mock.Anything).Return(&model.User{ ID: 1, Login: "6543", AccessToken: "token", }, nil) mockStore.On("GetRepoNameFallback", mock.Anything, mock.Anything, mock.Anything).Return(&model.Repo{ ID: 1, ForgeRemoteID: "1", Owner: "6543", Name: "hello-world", UserID: 1, }, nil) // Set up context with mock store ctx = store.InjectToContext(ctx, mockStore) // Create a mock client c := &client{ API: defaultAPI, url: defaultURL, } t.Run("convert push from webhook", func(t *testing.T) { // Create a mock HTTP request with a push event payload req := httptest.NewRequest("POST", "/hook", strings.NewReader(fixtures.HookPush)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", "push") // Call the Hook function repo, pipeline, err := c.Hook(ctx, req) assert.NoError(t, err) assert.NotNil(t, repo) assert.NotNil(t, pipeline) assert.Equal(t, model.EventPush, pipeline.Event) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, "refs/heads/main", pipeline.Ref) assert.Equal(t, "366701fde727cb7a9e7f21eb88264f59f6f9b89c", pipeline.Commit) assert.Equal(t, "Fix multiline secrets replacer (#700)\n\n* Fix multiline secrets replacer\r\n\r\n* Add tests", pipeline.Message) assert.Equal(t, "https://github.com/woodpecker-ci/woodpecker/commit/366701fde727cb7a9e7f21eb88264f59f6f9b89c", pipeline.ForgeURL) assert.Equal(t, "6543", pipeline.Author) assert.Equal(t, "https://avatars.githubusercontent.com/u/24977596?v=4", pipeline.Avatar) assert.Equal(t, "admin@philipp.info", pipeline.Email) assert.Equal(t, []string{"main.go"}, pipeline.ChangedFiles) }) t.Run("convert pull request from webhook", func(t *testing.T) { // Create a mock HTTP request with a pull request event payload req := httptest.NewRequest("POST", "/hook", strings.NewReader(fixtures.HookPullRequest)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", "pull_request") // Call the Hook function repo, pipeline, err := c.Hook(ctx, req) assert.NoError(t, err) assert.NotNil(t, repo) assert.NotNil(t, pipeline) assert.Equal(t, model.EventPull, pipeline.Event) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, "refs/pull/1/head", pipeline.Ref) assert.Equal(t, "changes:main", pipeline.Refspec) assert.Equal(t, "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", pipeline.Commit) assert.Equal(t, "Update the README with new information", pipeline.Message) assert.Equal(t, "Update the README with new information", pipeline.Title) assert.Equal(t, "baxterthehacker", pipeline.Author) assert.Equal(t, "https://avatars.githubusercontent.com/u/6752317?v=3", pipeline.Avatar) assert.Equal(t, "octocat", pipeline.Sender) assert.Equal(t, []string{"README.md", "main.go"}, pipeline.ChangedFiles) }) t.Run("convert deployment from webhook", func(t *testing.T) { // Create a mock HTTP request with a deployment event payload req := httptest.NewRequest("POST", "/hook", strings.NewReader(fixtures.HookDeploy)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", "deployment") // Call the Hook function repo, pipeline, err := c.Hook(ctx, req) assert.NoError(t, err) assert.NotNil(t, repo) assert.NotNil(t, pipeline) assert.Equal(t, model.EventDeploy, pipeline.Event) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, "refs/heads/main", pipeline.Ref) assert.Equal(t, "9049f1265b7d61be4a8904a9a27120d2064dab3b", pipeline.Commit) assert.Equal(t, "", pipeline.Message) assert.Equal(t, "https://api.github.com/repos/baxterthehacker/public-repo/deployments/710692", pipeline.ForgeURL) assert.Equal(t, "baxterthehacker", pipeline.Author) assert.Equal(t, "https://avatars.githubusercontent.com/u/6752317?v=3", pipeline.Avatar) }) t.Run("convert tag from webhook", func(t *testing.T) { // Create a mock HTTP request with a tag event payload but push event header (tags create push events at github) req := httptest.NewRequest("POST", "/hook", strings.NewReader(fixtures.HookTag)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", "push") // Call the Hook function repo, pipeline, err := c.Hook(ctx, req) assert.NoError(t, err) assert.NotNil(t, repo) assert.NotNil(t, pipeline) assert.Equal(t, model.EventTag, pipeline.Event) assert.Equal(t, "main", pipeline.Branch) assert.Equal(t, "refs/tags/the-tag-v1", pipeline.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", pipeline.Commit) assert.Equal(t, "Update .woodpecker.yml", pipeline.Message) assert.Equal(t, "https://github.com/6543/test_ci_tmp/commit/67012991d6c69b1c58378346fca366b864d8d1a1", pipeline.ForgeURL) assert.Equal(t, "6543", pipeline.Author) assert.Equal(t, "https://avatars.githubusercontent.com/u/24977596?v=4", pipeline.Avatar) assert.Equal(t, "6543@obermui.de", pipeline.Email) assert.Empty(t, pipeline.ChangedFiles) }) } ================================================ FILE: server/forge/github/parse.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package github import ( "bytes" "fmt" "io" "net/http" "strings" "github.com/google/go-github/v86/github" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( hookField = "payload" actionOpen = "opened" actionReopen = "reopened" actionClose = "closed" actionSync = "synchronize" actionReleased = "released" actionAssigned = "assigned" actionConvertedToDraft = "converted_to_draft" actionDemilestoned = "demilestoned" actionEdited = "edited" actionLabeled = "labeled" actionLocked = "locked" actionMilestoned = "milestoned" actionReadyForReview = "ready_for_review" actionUnassigned = "unassigned" actionUnlabeled = "unlabeled" actionUnlocked = "unlocked" labelCleared = "label_cleared" labelUpdated = "label_updated" ) // parseHook parses a GitHub hook from an http.Request request and returns // Repo and Pipeline detail. If a hook type is unsupported nil values are returned. func parseHook(r *http.Request, merge bool) (_ *github.PullRequest, _ *model.Repo, _ *model.Pipeline, currCommit, prevCommit string, _ error) { var reader io.Reader = r.Body if payload := r.FormValue(hookField); payload != "" { reader = bytes.NewBufferString(payload) } raw, err := io.ReadAll(reader) if err != nil { return nil, nil, nil, "", "", err } payload, err := github.ParseWebHook(github.WebHookType(r), raw) if err != nil { return nil, nil, nil, "", "", err } switch hook := payload.(type) { case *github.PushEvent: repo, pipeline, curr, prev := parsePushHook(hook) return nil, repo, pipeline, curr, prev, nil case *github.DeploymentEvent: repo, pipeline := parseDeployHook(hook) return nil, repo, pipeline, "", "", nil case *github.PullRequestEvent: pr, repo, pipeline, err := parsePullHook(hook, merge) return pr, repo, pipeline, "", "", err case *github.ReleaseEvent: repo, pipeline := parseReleaseHook(hook) return nil, repo, pipeline, "", "", nil default: return nil, nil, nil, "", "", &types.ErrIgnoreEvent{Event: github.Stringify(hook)} } } // parsePushHook parses a push hook and returns the Repo and Pipeline details. // If the commit type is unsupported nil values are returned. func parsePushHook(hook *github.PushEvent) (_ *model.Repo, _ *model.Pipeline, curr, prev string) { if hook.Deleted != nil && *hook.Deleted { return nil, nil, "", "" } pipeline := &model.Pipeline{ Event: model.EventPush, Commit: hook.GetHeadCommit().GetID(), Ref: hook.GetRef(), ForgeURL: hook.GetHeadCommit().GetURL(), Branch: strings.ReplaceAll(hook.GetRef(), "refs/heads/", ""), Message: hook.GetHeadCommit().GetMessage(), Email: hook.GetHeadCommit().GetAuthor().GetEmail(), Avatar: hook.GetSender().GetAvatarURL(), Author: hook.GetSender().GetLogin(), Sender: hook.GetSender().GetLogin(), } repo := convertRepoHook(hook.GetRepo()) if len(pipeline.Author) == 0 { pipeline.Author = hook.GetHeadCommit().GetAuthor().GetLogin() } if strings.HasPrefix(pipeline.Ref, "refs/tags/") { // just kidding, this is actually a tag event. Why did this come as a push // event we'll never know! pipeline.Event = model.EventTag // For tags, if the base_ref (tag's base branch) is set, we're using it // as pipeline's branch so that we can filter events base on it if strings.HasPrefix(hook.GetBaseRef(), "refs/heads/") { pipeline.Branch = strings.ReplaceAll(hook.GetBaseRef(), "refs/heads/", "") } return repo, pipeline, "", "" } return repo, pipeline, hook.GetHeadCommit().GetID(), hook.GetBefore() } // parseDeployHook parses a deployment and returns the Repo and Pipeline details. // If the commit type is unsupported nil values are returned. func parseDeployHook(hook *github.DeploymentEvent) (*model.Repo, *model.Pipeline) { pipeline := &model.Pipeline{ Event: model.EventDeploy, Commit: hook.GetDeployment().GetSHA(), ForgeURL: hook.GetDeployment().GetURL(), Message: hook.GetDeployment().GetDescription(), Ref: hook.GetDeployment().GetRef(), Branch: hook.GetDeployment().GetRef(), Avatar: hook.GetSender().GetAvatarURL(), Author: hook.GetSender().GetLogin(), Sender: hook.GetSender().GetLogin(), DeployTo: hook.GetDeployment().GetEnvironment(), DeployTask: hook.GetDeployment().GetTask(), } // if the ref is a sha or short sha we need to manually construct the ref. if strings.HasPrefix(pipeline.Commit, pipeline.Ref) || pipeline.Commit == pipeline.Ref { pipeline.Branch = hook.GetRepo().GetDefaultBranch() pipeline.Ref = fmt.Sprintf("refs/heads/%s", pipeline.Branch) } // if the ref is a branch we should make sure it has refs/heads prefix if !strings.HasPrefix(pipeline.Ref, "refs/") { // branch or tag pipeline.Ref = fmt.Sprintf("refs/heads/%s", pipeline.Branch) } return convertRepo(hook.GetRepo()), pipeline } // parsePullHook parses a pull request hook and returns the Repo and Pipeline // details. func parsePullHook(hook *github.PullRequestEvent, merge bool) (*github.PullRequest, *model.Repo, *model.Pipeline, error) { event := model.EventPull eventAction := "" switch hook.GetAction() { case actionOpen, actionReopen, actionSync: // default case nothing to do case actionClose: event = model.EventPullClosed case actionAssigned, actionConvertedToDraft, actionDemilestoned, actionEdited, actionLabeled, actionLocked, actionMilestoned, actionReadyForReview, actionUnassigned, actionUnlabeled, actionUnlocked: // metadata pull events event = model.EventPullMetadata eventAction = common.NormalizeEventReason(hook.GetAction()) default: return nil, nil, nil, &types.ErrIgnoreEvent{ Event: string(model.EventPullMetadata), Reason: fmt.Sprintf("action %s is not supported", hook.GetAction()), } } fromFork := hook.GetPullRequest().GetHead().GetRepo().GetID() != hook.GetPullRequest().GetBase().GetRepo().GetID() pipeline := &model.Pipeline{ Event: event, EventReason: []string{eventAction}, Commit: hook.GetPullRequest().GetHead().GetSHA(), ForgeURL: hook.GetPullRequest().GetHTMLURL(), Ref: fmt.Sprintf(headRefs, hook.GetPullRequest().GetNumber()), Branch: hook.GetPullRequest().GetBase().GetRef(), Message: hook.GetPullRequest().GetTitle(), Author: hook.GetPullRequest().GetUser().GetLogin(), Avatar: hook.GetPullRequest().GetUser().GetAvatarURL(), Title: hook.GetPullRequest().GetTitle(), Sender: hook.GetSender().GetLogin(), Refspec: fmt.Sprintf(refSpec, hook.GetPullRequest().GetHead().GetRef(), hook.GetPullRequest().GetBase().GetRef(), ), PullRequestLabels: convertLabels(hook.GetPullRequest().Labels), PullRequestMilestone: hook.GetPullRequest().GetMilestone().GetTitle(), FromFork: fromFork, } if merge { pipeline.Ref = fmt.Sprintf(mergeRefs, hook.GetPullRequest().GetNumber()) } // normalize label events to match other forges if eventAction == actionLabeled || eventAction == actionUnlabeled { if len(pipeline.PullRequestLabels) == 0 { pipeline.EventReason = []string{labelCleared} } else { pipeline.EventReason = []string{labelUpdated} } } return hook.GetPullRequest(), convertRepo(hook.GetRepo()), pipeline, nil } // parseReleaseHook parses a release hook and returns the Repo and Pipeline // details. func parseReleaseHook(hook *github.ReleaseEvent) (*model.Repo, *model.Pipeline) { if hook.GetAction() != actionReleased { return nil, nil } name := hook.GetRelease().GetName() if name == "" { name = hook.GetRelease().GetTagName() } pipeline := &model.Pipeline{ Event: model.EventRelease, ForgeURL: hook.GetRelease().GetHTMLURL(), Ref: fmt.Sprintf("refs/tags/%s", hook.GetRelease().GetTagName()), Branch: hook.GetRelease().GetTargetCommitish(), // cspell:disable-line Message: fmt.Sprintf("created release %s", name), Author: hook.GetRelease().GetAuthor().GetLogin(), Avatar: hook.GetRelease().GetAuthor().GetAvatarURL(), Sender: hook.GetSender().GetLogin(), IsPrerelease: hook.GetRelease().GetPrerelease(), } return convertRepo(hook.GetRepo()), pipeline } ================================================ FILE: server/forge/github/parse_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package github import ( "bytes" "net/http" "sort" "strings" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/github/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const ( hookEvent = "X-GitHub-Event" hookDeploy = "deployment" hookPush = "push" hookPull = "pull_request" hookRelease = "release" ) func testHookRequest(payload []byte, event string) *http.Request { buf := bytes.NewBuffer(payload) req, _ := http.NewRequest(http.MethodPost, "/hook", buf) req.Header = http.Header{} req.Header.Set(hookEvent, event) return req } func Test_parseHook(t *testing.T) { t.Run("ignore unsupported hook events", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequest), "issues") p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.Nil(t, r) assert.Nil(t, b) assert.Nil(t, p) assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) }) t.Run("skip skip push hook when action is deleted", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPushDeleted), hookPush) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.Nil(t, r) assert.Nil(t, b) assert.NoError(t, err) assert.Nil(t, p) }) t.Run("push hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPush), hookPush) p, r, b, cc, pc, err := parseHook(req, false) assert.Equal(t, "2f780193b136b72bfea4eeb640786a8c4450c7a2", pc) assert.Equal(t, "366701fde727cb7a9e7f21eb88264f59f6f9b89c", cc) assert.NoError(t, err) assert.Nil(t, p) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPush, b.Event) sort.Strings(b.ChangedFiles) }) t.Run("PR hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequest), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.NotNil(t, p) assert.Equal(t, model.EventPull, b.Event) }) t.Run("PR closed hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestClosed), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.NotNil(t, p) assert.Equal(t, model.EventPullClosed, b.Event) }) t.Run("reopen a pull", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestReopened), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.NotNil(t, p) assert.Equal(t, model.EventPull, b.Event) }) t.Run("PR merged hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestMerged), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.NotNil(t, p) assert.Equal(t, model.EventPullClosed, b.Event) }) t.Run("PR edited hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestEdited), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.NotNil(t, p) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"edited"}, b.EventReason) }) t.Run("deploy hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookDeploy), hookDeploy) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Nil(t, p) assert.Equal(t, model.EventDeploy, b.Event) assert.Equal(t, "production", b.DeployTo) assert.Equal(t, "deploy", b.DeployTask) }) t.Run("release hook", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookRelease), hookRelease) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Nil(t, p) assert.Equal(t, model.EventRelease, b.Event) assert.Len(t, strings.Split(b.Ref, "/"), 3) assert.True(t, strings.HasPrefix(b.Ref, "refs/tags/")) }) t.Run("pull review requested", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestReviewRequested), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) assert.Nil(t, r) assert.Nil(t, b) assert.Nil(t, p) }) t.Run("pull milestoned", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestMilestoneAdded), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"milestoned"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) assert.Equal(t, "yeaaa", *p.Body) assert.Equal(t, false, *p.Draft) assert.Equal(t, false, *p.Merged) assert.Equal(t, true, *p.Mergeable) assert.Equal(t, "unstable", *p.MergeableState) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } if assert.NotNil(t, p.Milestone) { assert.Equal(t, int64(13392101), *p.Milestone.ID) assert.Equal(t, 2, *p.Milestone.Number) assert.Equal(t, "open mile", *p.Milestone.Title) assert.Equal(t, "ongoing", *p.Milestone.Description) assert.Equal(t, "open", *p.Milestone.State) if assert.NotNil(t, p.Milestone.Creator) { assert.Equal(t, "demoaccount2-commits", *p.Milestone.Creator.Login) assert.Equal(t, int64(223550959), *p.Milestone.Creator.ID) } } assert.Empty(t, p.RequestedReviewers) if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) // milestone change will result two webhooks an demilestoned and milestoned t.Run("pull request demilestoned", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestMilestoneRemoved), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"demilestoned"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } if assert.Len(t, p.Labels, 1) { assert.Equal(t, int64(9024465370), *p.Labels[0].ID) assert.Equal(t, "bug", *p.Labels[0].Name) assert.Equal(t, "d73a4a", *p.Labels[0].Color) assert.Equal(t, "Something isn't working", *p.Labels[0].Description) } assert.Nil(t, p.Milestone) if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) t.Run("pull request labele added", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestLabelAdded), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"label_updated"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) assert.Equal(t, "yeaaa", *p.Body) assert.Equal(t, false, *p.Draft) assert.Equal(t, false, *p.Merged) assert.Equal(t, true, *p.Mergeable) assert.Equal(t, "unstable", *p.MergeableState) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } if assert.Len(t, p.Labels, 2) { assert.Equal(t, int64(9024465376), *p.Labels[0].ID) assert.Equal(t, "documentation", *p.Labels[0].Name) assert.Equal(t, "0075ca", *p.Labels[0].Color) assert.Equal(t, "Improvements or additions to documentation", *p.Labels[0].Description) assert.Equal(t, int64(9024465382), *p.Labels[1].ID) assert.Equal(t, "enhancement", *p.Labels[1].Name) assert.Equal(t, "a2eeef", *p.Labels[1].Color) assert.Equal(t, "New feature or request", *p.Labels[1].Description) } if assert.NotNil(t, p.Milestone) { assert.Equal(t, int64(13392101), *p.Milestone.ID) assert.Equal(t, "open mile", *p.Milestone.Title) } assert.Empty(t, p.RequestedReviewers) if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) // lable change will result two webhooks an unlable and labeled t.Run("pull request got label removed", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestLabelRemoved), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"label_updated"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } if assert.Len(t, p.Labels, 1) { assert.Equal(t, int64(9024465370), *p.Labels[0].ID) assert.Equal(t, "bug", *p.Labels[0].Name) assert.Equal(t, "d73a4a", *p.Labels[0].Color) assert.Equal(t, "Something isn't working", *p.Labels[0].Description) } if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) t.Run("pull request got all label removed", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestLabelsCleared), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"label_cleared"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) assert.Empty(t, p.Labels) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) t.Run("pull request assigned", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestAssigneeAdded), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"assigned"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } if assert.NotNil(t, p.Assignee) { assert.Equal(t, "demoaccount2-commits", *p.Assignee.Login) assert.Equal(t, int64(223550959), *p.Assignee.ID) } if assert.Len(t, p.Assignees, 1) { assert.Equal(t, "demoaccount2-commits", *p.Assignees[0].Login) assert.Equal(t, int64(223550959), *p.Assignees[0].ID) } if assert.Len(t, p.Labels, 1) { assert.Equal(t, int64(9024465370), *p.Labels[0].ID) assert.Equal(t, "bug", *p.Labels[0].Name) } assert.Nil(t, p.Milestone) if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) // assigne change will result two webhooks an assigned and unassigned t.Run("pull request unassigned", func(t *testing.T) { req := testHookRequest([]byte(fixtures.HookPullRequestAssigneeRemoved), hookPull) p, r, b, cc, pc, err := parseHook(req, false) assert.Empty(t, pc) assert.Empty(t, cc) assert.NoError(t, err) assert.NotNil(t, r) assert.NotNil(t, b) assert.Equal(t, model.EventPullMetadata, b.Event) assert.Equal(t, []string{"unassigned"}, b.EventReason) if assert.NotNil(t, p) { assert.Equal(t, int64(2705176047), *p.ID) assert.Equal(t, 1, *p.Number) assert.Equal(t, "open", *p.State) assert.Equal(t, "Some ned more AAAA", *p.Title) if assert.NotNil(t, p.User) { assert.Equal(t, "6543", *p.User.Login) assert.Equal(t, int64(24977596), *p.User.ID) } assert.Nil(t, p.Assignee) assert.Empty(t, p.Assignees) if assert.Len(t, p.Labels, 1) { assert.Equal(t, int64(9024465370), *p.Labels[0].ID) assert.Equal(t, "bug", *p.Labels[0].Name) } assert.Nil(t, p.Milestone) if assert.NotNil(t, p.Head) { assert.Equal(t, "6543-patch-1", *p.Head.Ref) assert.Equal(t, "36b5813240a9d2daa29b05046d56a53e18f39a3e", *p.Head.SHA) } if assert.NotNil(t, p.Base) { assert.Equal(t, "main", *p.Base.Ref) assert.Equal(t, "67012991d6c69b1c58378346fca366b864d8d1a1", *p.Base.SHA) } } }) } ================================================ FILE: server/forge/gitlab/convert.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitlab import ( "crypto/md5" "encoding/hex" "fmt" "net/http" "strings" gitlab "gitlab.com/gitlab-org/api/client-go/v2" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( mergeRefs = "refs/merge-requests/%d/head" // merge request merged with base // GitLab project visibility_level values, as sent in webhook payloads. // See https://docs.gitlab.com/api/projects/#project-visibility-level. visibilityLevelPrivate = 0 visibilityLevelInternal = 10 visibilityLevelPublic = 20 stateOpened = "opened" actionOpen = "open" actionClose = "close" actionReopen = "reopen" actionMerge = "merge" actionUpdate = "update" metadataReasonAssigned = "assigned" metadataReasonUnassigned = "unassigned" metadataReasonMilestoned = "milestoned" metadataReasonDemilestoned = "demilestoned" metadataReasonTitleEdited = "title_edited" metadataReasonDescriptionEdited = "description_edited" metadataReasonLabelsAdded = "labels_added" metadataReasonLabelsCleared = "labels_cleared" metadataReasonLabelsUpdated = "labels_updated" metadataReasonReviewRequested = "review_requested" ) func (g *GitLab) convertGitLabRepo(_repo *gitlab.Project, projectMember *gitlab.ProjectMember) (*model.Repo, error) { parts := strings.Split(_repo.PathWithNamespace, "/") owner := strings.Join(parts[:len(parts)-1], "/") name := parts[len(parts)-1] repo := &model.Repo{ ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(_repo.ID)), Owner: owner, Name: name, FullName: _repo.PathWithNamespace, Avatar: _repo.AvatarURL, ForgeURL: _repo.WebURL, Clone: _repo.HTTPURLToRepo, CloneSSH: _repo.SSHURLToRepo, Branch: _repo.DefaultBranch, Visibility: model.RepoVisibility(_repo.Visibility), IsSCMPrivate: _repo.Visibility == gitlab.InternalVisibility || _repo.Visibility == gitlab.PrivateVisibility, Perm: &model.Perm{ Pull: isRead(_repo, projectMember), Push: isWrite(projectMember), Admin: isAdmin(projectMember), }, PREnabled: _repo.MergeRequestsAccessLevel != gitlab.DisabledAccessControl, } if len(repo.Avatar) != 0 && !strings.HasPrefix(repo.Avatar, "http") { repo.Avatar = fmt.Sprintf("%s/%s", g.url, repo.Avatar) } return repo, nil } func convertMergeRequestHook(hook *gitlab.MergeEvent, req *http.Request) (mergeID, milestoneID int64, repo *model.Repo, pipeline *model.Pipeline, err error) { repo = &model.Repo{} pipeline = &model.Pipeline{} target := hook.ObjectAttributes.Target source := hook.ObjectAttributes.Source obj := hook.ObjectAttributes switch obj.Action { case actionClose, actionMerge: // pull close event pipeline.Event = model.EventPullClosed case actionOpen, actionReopen: // pull open event -> pull event pipeline.Event = model.EventPull case actionUpdate: if obj.OldRev != "" && obj.State == stateOpened { // if some git action happened then OldRev != "" -> it's a normal pull_request trigger // https://github.com/woodpecker-ci/woodpecker/pull/3338 // https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#merge-request-events pipeline.Event = model.EventPull break } pipeline.Event = model.EventPullMetadata // All changes are just update actions ... so we have to look into the changes section var reason []string if len(hook.Changes.Assignees.Current) != 0 { reason = append(reason, metadataReasonAssigned) } if len(hook.Changes.Assignees.Previous) != 0 { reason = append(reason, metadataReasonUnassigned) } if hook.Changes.MilestoneID.Current != 0 { reason = append(reason, metadataReasonMilestoned) } if hook.Changes.MilestoneID.Previous != 0 { reason = append(reason, metadataReasonDemilestoned) } if len(hook.Changes.Title.Current) != 0 || len(hook.Changes.Title.Previous) != 0 { reason = append(reason, metadataReasonTitleEdited) } if len(hook.Changes.Description.Current) != 0 || len(hook.Changes.Description.Previous) != 0 { reason = append(reason, metadataReasonDescriptionEdited) } switch { case len(hook.Changes.Labels.Current) != 0 && len(hook.Changes.Labels.Previous) == 0: reason = append(reason, metadataReasonLabelsAdded) case len(hook.Changes.Labels.Current) == 0 && len(hook.Changes.Labels.Previous) != 0: reason = append(reason, metadataReasonLabelsCleared) case len(hook.Changes.Labels.Current) != 0 && len(hook.Changes.Labels.Previous) != 0: reason = append(reason, metadataReasonLabelsUpdated) } if len(hook.Changes.Reviewers.Current) > len(hook.Changes.Reviewers.Previous) { reason = append(reason, metadataReasonReviewRequested) } for i := range reason { reason[i] = common.NormalizeEventReason(reason[i]) } pipeline.EventReason = reason if len(pipeline.EventReason) == 0 { return 0, 0, nil, nil, &types.ErrIgnoreEvent{ Event: "Merge Request Hook", Reason: fmt.Sprintf("Action '%s' no supported changes detected", obj.Action), } } default: // non supported action return 0, 0, nil, nil, &types.ErrIgnoreEvent{ Event: "Merge Request Hook", Reason: fmt.Sprintf("Action '%s' not supported", obj.Action), } } switch { case target == nil && source == nil: return 0, 0, nil, nil, fmt.Errorf("target and source keys expected in merge request hook") case target == nil: return 0, 0, nil, nil, fmt.Errorf("target key expected in merge request hook") case source == nil: return 0, 0, nil, nil, fmt.Errorf("source key expected in merge request hook") } if target.PathWithNamespace != "" { var err error if repo.Owner, repo.Name, err = extractFromPath(target.PathWithNamespace); err != nil { return 0, 0, nil, nil, err } repo.FullName = target.PathWithNamespace } else { repo.Owner = req.FormValue("owner") repo.Name = req.FormValue("name") repo.FullName = fmt.Sprintf("%s/%s", repo.Owner, repo.Name) } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(obj.TargetProjectID)) repo.ForgeURL = target.WebURL if target.GitHTTPURL != "" { repo.Clone = target.GitHTTPURL } else { repo.Clone = target.HTTPURL } if target.GitSSHURL != "" { repo.CloneSSH = target.GitSSHURL } else { repo.CloneSSH = target.SSHURL } repo.Branch = target.DefaultBranch if target.AvatarURL != "" { repo.Avatar = target.AvatarURL } lastCommit := obj.LastCommit pipeline.Message = lastCommit.Message pipeline.Commit = lastCommit.ID pipeline.Ref = fmt.Sprintf(mergeRefs, obj.IID) pipeline.Branch = obj.SourceBranch pipeline.Refspec = fmt.Sprintf("%s:%s", obj.SourceBranch, obj.TargetBranch) author := lastCommit.Author pipeline.Author = author.Name pipeline.Email = author.Email if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } pipeline.Title = obj.Title pipeline.ForgeURL = obj.URL pipeline.PullRequestLabels = convertLabels(hook.Labels) pipeline.FromFork = target.PathWithNamespace != source.PathWithNamespace return obj.IID, hook.ObjectAttributes.MilestoneID, repo, pipeline, nil } func convertPushHook(hook *gitlab.PushEvent) (*model.Repo, *model.Pipeline, error) { repo := &model.Repo{} pipeline := &model.Pipeline{} var err error if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil { return nil, nil, err } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.ProjectID)) repo.Avatar = hook.Project.AvatarURL repo.ForgeURL = hook.Project.WebURL repo.Clone = hook.Project.GitHTTPURL repo.CloneSSH = hook.Project.GitSSHURL repo.FullName = hook.Project.PathWithNamespace repo.Branch = hook.Project.DefaultBranch // GitLab does not send `project.visibility` (string) in push event // payloads — only `project.visibility_level` (numeric), which the // go-gitlab library does not expose on PushEventProject. So this switch // is a no-op for real-world payloads, leaving Visibility/IsSCMPrivate // at zero values. model.Repo.Update() must therefore guard against // overwriting the value previously synced via the forge API. switch hook.Project.Visibility { case gitlab.PrivateVisibility: repo.Visibility = model.VisibilityPrivate repo.IsSCMPrivate = true case gitlab.InternalVisibility: repo.Visibility = model.VisibilityInternal repo.IsSCMPrivate = true case gitlab.PublicVisibility: repo.Visibility = model.VisibilityPublic repo.IsSCMPrivate = false } pipeline.Event = model.EventPush pipeline.Commit = hook.After pipeline.Branch = strings.TrimPrefix(hook.Ref, "refs/heads/") pipeline.Ref = hook.Ref // assume a capacity of 4 changed files per commit files := make([]string, 0, len(hook.Commits)*4) for _, cm := range hook.Commits { if hook.After == cm.ID { pipeline.Author = cm.Author.Name pipeline.Email = cm.Author.Email pipeline.Message = cm.Message pipeline.Timestamp = cm.Timestamp.Unix() if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } } files = append(files, cm.Added...) files = append(files, cm.Removed...) files = append(files, cm.Modified...) } pipeline.ChangedFiles = utils.DeduplicateStrings(files) return repo, pipeline, nil } func convertTagHook(hook *gitlab.TagEvent) (*model.Repo, *model.Pipeline, string, error) { repo := &model.Repo{} pipeline := &model.Pipeline{} var err error if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil { return nil, nil, "", err } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.ProjectID)) repo.Avatar = hook.Project.AvatarURL repo.ForgeURL = hook.Project.WebURL repo.Clone = hook.Project.GitHTTPURL repo.CloneSSH = hook.Project.GitSSHURL repo.FullName = hook.Project.PathWithNamespace repo.Branch = hook.Project.DefaultBranch // See note in convertPushHook: tag event payloads also omit // `project.visibility`, so this switch typically does nothing. switch hook.Project.Visibility { case gitlab.PrivateVisibility: repo.Visibility = model.VisibilityPrivate repo.IsSCMPrivate = true case gitlab.InternalVisibility: repo.Visibility = model.VisibilityInternal repo.IsSCMPrivate = true case gitlab.PublicVisibility: repo.Visibility = model.VisibilityPublic repo.IsSCMPrivate = false } refTag := strings.TrimPrefix(hook.Ref, "refs/heads/") pipeline.Event = model.EventTag pipeline.Commit = hook.After pipeline.Branch = refTag pipeline.Ref = hook.Ref for _, cm := range hook.Commits { if hook.After == cm.ID { pipeline.Author = cm.Author.Name pipeline.Email = cm.Author.Email pipeline.Message = cm.Message pipeline.Timestamp = cm.Timestamp.Unix() if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } break } } return repo, pipeline, hook.After, nil } func convertReleaseHook(hook *gitlab.ReleaseEvent) (*model.Repo, *model.Pipeline, error) { repo := &model.Repo{} var err error if repo.Owner, repo.Name, err = extractFromPath(hook.Project.PathWithNamespace); err != nil { return nil, nil, err } repo.ForgeRemoteID = model.ForgeRemoteID(fmt.Sprint(hook.Project.ID)) repo.Avatar = "" if hook.Project.AvatarURL != nil { repo.Avatar = *hook.Project.AvatarURL } repo.ForgeURL = hook.Project.WebURL repo.Clone = hook.Project.GitHTTPURL repo.CloneSSH = hook.Project.GitSSHURL repo.FullName = hook.Project.PathWithNamespace repo.Branch = hook.Project.DefaultBranch // Release events expose visibility as a numeric level (unlike push/tag // which omit it from the payload entirely). Map it to both Visibility // and IsSCMPrivate so model.Repo.Update() will propagate the value. switch hook.Project.VisibilityLevel { case visibilityLevelPrivate: repo.Visibility = model.VisibilityPrivate repo.IsSCMPrivate = true case visibilityLevelInternal: repo.Visibility = model.VisibilityInternal repo.IsSCMPrivate = true case visibilityLevelPublic: repo.Visibility = model.VisibilityPublic repo.IsSCMPrivate = false } pipeline := &model.Pipeline{ Event: model.EventRelease, Commit: hook.Commit.ID, ForgeURL: hook.URL, Message: fmt.Sprintf("created release %s", hook.Name), Sender: hook.Commit.Author.Name, Author: hook.Commit.Author.Name, Email: hook.Commit.Author.Email, // Tag name here is the ref. We should add the refs/tags, so // it is known it's a tag (git-plugin looks for it) Ref: "refs/tags/" + hook.Tag, } if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } return repo, pipeline, nil } func getUserAvatar(email string) string { hasher := md5.New() hasher.Write([]byte(email)) return fmt.Sprintf( "%s/%v.jpg?s=%s", gravatarBase, hex.EncodeToString(hasher.Sum(nil)), "128", ) } // extractFromPath splits a repository path string into owner and name components. // It requires at least two path components, otherwise an error is returned. func extractFromPath(str string) (string, string, error) { const minPathComponents = 2 s := strings.Split(str, "/") if len(s) < minPathComponents { return "", "", fmt.Errorf("minimum match not found") } owner := strings.Join(s[:len(s)-1], "/") name := s[len(s)-1] return owner, name, nil } func convertLabels(from []*gitlab.EventLabel) []string { labels := make([]string, len(from)) for i, label := range from { labels[i] = label.Title } return labels } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestApproved.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "some@mail.info" }, "project": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "object_attributes": { "assignee_id": 2251488, "author_id": 2251488, "created_at": "2022-01-10 15:23:41 UTC", "description": "", "head_pipeline_id": 449733536, "id": 134400602, "iid": 3, "last_edited_at": "2022-01-17 15:46:23 UTC", "last_edited_by_id": 2251488, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "anbraten-main-patch-05373", "source_project_id": 32059612, "state_id": 1, "target_branch": "main", "target_project_id": 32059612, "time_estimate": 0, "title": "Update client.go 🎉", "updated_at": "2022-01-17 15:47:39 UTC", "updated_by_id": 2251488, "url": "https://gitlab.com/anbraten/woodpecker/-/merge_requests/3", "source": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "target": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "last_commit": { "id": "c136499ec574e1034b24c5d306de9acda3005367", "message": "Update folder/todo.txt", "title": "Update folder/todo.txt", "timestamp": "2022-01-17T15:47:38+00:00", "url": "https://gitlab.com/anbraten/woodpecker/-/commit/c136499ec574e1034b24c5d306de9acda3005367", "author": { "name": "Anbraten", "email": "some@mail.info" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [2251488], "state": "opened", "blocking_discussions_resolved": true, "action": "approved" }, "labels": [], "changes": { "updated_at": { "previous": "2022-01-17 15:46:23 UTC", "current": "2022-01-17 15:47:39 UTC" } }, "repository": { "name": "woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "description": "", "homepage": "https://gitlab.com/anbraten/woodpecker" }, "assignees": [ { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "some@mail.info" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestAssigned.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 4575606, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:23:04 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [4575606], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "updated_at": { "previous": "2025-08-06 01:21:37 UTC", "current": "2025-08-06 01:23:04 UTC" }, "assignees": { "previous": [], "current": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestClosed.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "[REDACTED]" }, "project": { "id": 32059612, "name": "woodpecker-test", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker-test", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker-test.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker-test", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "http_url": "https://gitlab.com/anbraten/woodpecker-test.git" }, "object_attributes": { "assignee_id": null, "author_id": 2251488, "created_at": "2023-12-05 18:40:22 UTC", "description": "", "draft": false, "head_pipeline_id": null, "id": 268189426, "iid": 4, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "patch-1", "source_project_id": 32059612, "state_id": 2, "target_branch": "main", "target_project_id": 32059612, "time_estimate": 0, "title": "Add new file", "updated_at": "2023-12-05 18:40:34 UTC", "updated_by_id": null, "url": "https://gitlab.com/anbraten/woodpecker-test/-/merge_requests/4", "source": { "id": 32059612, "name": "woodpecker-test", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker-test", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker-test.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker-test", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "http_url": "https://gitlab.com/anbraten/woodpecker-test.git" }, "target": { "id": 32059612, "name": "woodpecker-test", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker-test", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker-test.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker-test", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "http_url": "https://gitlab.com/anbraten/woodpecker-test.git" }, "last_commit": { "id": "3e4db3586b65dd401de8c77b3ac343fd24cbf89b", "message": "Add new file", "title": "Add new file", "timestamp": "2023-12-05T18:39:57+00:00", "url": "https://gitlab.com/anbraten/woodpecker-test/-/commit/3e4db3586b65dd401de8c77b3ac343fd24cbf89b", "author": { "name": "Anbraten", "email": "[redacted]" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [], "reviewer_ids": [], "labels": [], "state": "closed", "blocking_discussions_resolved": true, "first_contribution": false, "detailed_merge_status": "not_open", "action": "close" }, "labels": [], "changes": { "state_id": { "previous": 1, "current": 2 }, "updated_at": { "previous": "2023-12-05 18:40:28 UTC", "current": "2023-12-05 18:40:34 UTC" } }, "repository": { "name": "woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "description": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test" } } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestDemilestoned.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 29352624, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:25:34 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [29352624], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "milestone_id": { "previous": 6088906, "current": null }, "updated_at": { "previous": "2025-08-06 01:24:11 UTC", "current": "2025-08-06 01:25:34 UTC" } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 29352624, "name": "demoaccount2-commits", "username": "demoaccount2-commits", "avatar_url": "https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestEdited.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 4575606, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-05 22:01:30 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [4575606], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "description": { "previous": ":tada: text that you might read eventually", "current": ":tada: text that you might read eventually." }, "last_edited_at": { "previous": null, "current": "2025-08-05 22:01:30 UTC" }, "last_edited_by_id": { "previous": null, "current": 4575606 }, "title": { "previous": "Edit README.md for more text to read", "current": "Edit README for more text to read" }, "updated_at": { "previous": "2025-08-05 21:48:27 UTC", "current": "2025-08-05 22:01:30 UTC" }, "updated_by_id": { "previous": null, "current": 4575606 } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestLabelsAdded.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:21:37 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "labels": { "previous": [], "current": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" } } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestLabelsCleared.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:21:37 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [], "changes": { "updated_at": { "previous": "2025-08-06 01:20:04 UTC", "current": "2025-08-06 01:21:37 UTC" }, "labels": { "previous": [ { "id": 41869665, "title": "enhancement", "color": "#a2eeef", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null }, { "id": 41869667, "title": "help wanted", "color": "#008672", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "current": [] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" } } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestLabelsUpdated.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:20:04 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869665, "title": "enhancement", "color": "#a2eeef", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null }, { "id": 41869667, "title": "help wanted", "color": "#008672", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869665, "title": "enhancement", "color": "#a2eeef", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null }, { "id": 41869667, "title": "help wanted", "color": "#008672", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "updated_at": { "previous": "2025-08-06 01:18:10 UTC", "current": "2025-08-06 01:20:04 UTC" }, "labels": { "previous": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "current": [ { "id": 41869665, "title": "enhancement", "color": "#a2eeef", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null }, { "id": 41869667, "title": "help wanted", "color": "#008672", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" } } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestMerged.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "[REDACTED]" }, "project": { "id": 32059612, "name": "woodpecker-test", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker-test", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker-test.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker-test", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "http_url": "https://gitlab.com/anbraten/woodpecker-test.git" }, "object_attributes": { "assignee_id": null, "author_id": 2251488, "created_at": "2023-12-05 18:40:22 UTC", "description": "", "draft": false, "head_pipeline_id": null, "id": 268189426, "iid": 4, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": "43411b53d670203e887c4985c4e58e8e6b7c109e", "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "patch-1", "source_project_id": 32059612, "state_id": 3, "target_branch": "main", "target_project_id": 32059612, "time_estimate": 0, "title": "Add new file", "updated_at": "2023-12-05 18:43:00 UTC", "updated_by_id": null, "url": "https://gitlab.com/anbraten/woodpecker-test/-/merge_requests/4", "source": { "id": 32059612, "name": "woodpecker-test", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker-test", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker-test.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker-test", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "http_url": "https://gitlab.com/anbraten/woodpecker-test.git" }, "target": { "id": 32059612, "name": "woodpecker-test", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker-test", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker-test.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker-test", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker-test.git", "http_url": "https://gitlab.com/anbraten/woodpecker-test.git" }, "last_commit": { "id": "3e4db3586b65dd401de8c77b3ac343fd24cbf89b", "message": "Add new file", "title": "Add new file", "timestamp": "2023-12-05T18:39:57+00:00", "url": "https://gitlab.com/anbraten/woodpecker-test/-/commit/3e4db3586b65dd401de8c77b3ac343fd24cbf89b", "author": { "name": "Anbraten", "email": "[redacted]" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [], "reviewer_ids": [], "labels": [], "state": "merged", "blocking_discussions_resolved": true, "first_contribution": false, "detailed_merge_status": "not_open", "action": "merge" }, "labels": [], "changes": { "state_id": { "previous": 4, "current": 3 }, "updated_at": { "previous": "2023-12-05 18:43:00 UTC", "current": "2023-12-05 18:43:00 UTC" } }, "repository": { "name": "woodpecker-test", "url": "git@gitlab.com:anbraten/woodpecker-test.git", "description": "", "homepage": "https://gitlab.com/anbraten/woodpecker-test" } } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestMilestoned.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 29352624, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:27:00 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [29352624], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "milestone_id": { "previous": null, "current": 6088906 }, "updated_at": { "previous": "2025-08-06 01:25:34 UTC", "current": "2025-08-06 01:27:00 UTC" } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 29352624, "name": "demoaccount2-commits", "username": "demoaccount2-commits", "avatar_url": "https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestOpened.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 4575606, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "checking", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README.md for more text to read", "updated_at": "2025-08-05 21:48:27 UTC", "updated_by_id": null, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [4575606], "blocking_discussions_resolved": true, "detailed_merge_status": "checking", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "open" }, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "merge_status": { "previous": "preparing", "current": "checking" }, "updated_at": { "previous": "2025-08-05 21:48:25 UTC", "current": "2025-08-05 21:48:27 UTC" }, "prepared_at": { "previous": null, "current": "2025-08-05 21:48:27 UTC" } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestReopened.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 29352663, "created_at": "2025-07-29 20:00:54 UTC", "description": "yeaaa", "draft": false, "head_pipeline_id": null, "id": 403287663, "iid": 1, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": {}, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "6543-patch-1", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Some ned more AAAA", "updated_at": "2025-08-05 14:44:26 UTC", "updated_by_id": null, "prepared_at": null, "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "message": "Some ned more AAAA\n", "title": "Some ned more AAAA", "timestamp": "2025-07-29T16:45:02+02:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/36b5813240a9d2daa29b05046d56a53e18f39a3e", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [29352668], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/1", "work_in_progress": false, "approval_rules": [], "action": "reopen" }, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "state_id": { "previous": 2, "current": 1 }, "updated_at": { "previous": "2025-08-05 14:44:14 UTC", "current": "2025-08-05 14:44:26 UTC" } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "reviewers": [ { "id": 29352668, "name": "Placeholder github Source User", "username": "demoaccount2commits_placeholder_6s82rp", "avatar_url": "https://secure.gravatar.com/avatar/6d8b40c6bd417e69e359e712b55471dfc72af88c732fed9fe8d276a210aa5dd8?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestReviewRequestDel.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:18:10 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "reviewers": { "previous": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ], "current": [] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" } } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestReviewRequested.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:11:08 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [4575606], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "updated_at": { "previous": "2025-08-06 01:09:39 UTC", "current": "2025-08-06 01:11:08 UTC" }, "reviewers": { "previous": [], "current": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "reviewers": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestUnapproved.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 29352624, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:28:57 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [29352624], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "unapproved" }, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": {}, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 29352624, "name": "demoaccount2-commits", "username": "demoaccount2-commits", "avatar_url": "https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestUnassigned.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": 29352624, "author_id": 4575606, "created_at": "2025-08-05 21:48:25 UTC", "description": ":tada: text that you might read eventually.", "draft": false, "head_pipeline_id": null, "id": 405095454, "iid": 3, "last_edited_at": "2025-08-05 22:01:30 UTC", "last_edited_by_id": 4575606, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "0" }, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": 6088906, "source_branch": "real6543-main-patch-42541", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Edit README for more text to read", "updated_at": "2025-08-06 01:24:11 UTC", "updated_by_id": 4575606, "prepared_at": "2025-08-05 21:48:27 UTC", "assignee_ids": [29352624], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "2f7670508b771e7e77839402be8b34b13787aba8", "message": "Edit README.md", "title": "Edit README.md", "timestamp": "2025-08-05T21:45:58+00:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/2f7670508b771e7e77839402be8b34b13787aba8", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/3", "work_in_progress": false, "approval_rules": [], "action": "update" }, "labels": [ { "id": 41869666, "title": "good first issue", "color": "#7057ff", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": { "assignees": { "previous": [ { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" } ], "current": [ { "id": 29352624, "name": "demoaccount2-commits", "username": "demoaccount2-commits", "avatar_url": "https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon", "email": "[REDACTED]" } ] } }, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "assignees": [ { "id": 29352624, "name": "demoaccount2-commits", "username": "demoaccount2-commits", "avatar_url": "https://secure.gravatar.com/avatar/510e64831d3748ef1d2143c4a9766c30684f17991b31ee23ece8bb2f26a35fc2?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestUnsupportedAction.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 4575606, "name": "6543", "username": "real6543", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/4575606/avatar.png", "email": "[REDACTED]" }, "project": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "object_attributes": { "assignee_id": null, "author_id": 29352663, "created_at": "2025-07-29 20:00:54 UTC", "description": "yeaaa", "draft": false, "head_pipeline_id": null, "id": 403287663, "iid": 1, "last_edited_at": null, "last_edited_by_id": null, "merge_commit_sha": null, "merge_error": null, "merge_params": {}, "merge_status": "can_be_merged", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "6543-patch-1", "source_project_id": 72081820, "state_id": 1, "target_branch": "main", "target_project_id": 72081820, "time_estimate": 0, "title": "Some ned more AAAA", "updated_at": "2025-07-30 00:46:56 UTC", "updated_by_id": null, "prepared_at": null, "assignee_ids": [], "blocking_discussions_resolved": true, "detailed_merge_status": "mergeable", "first_contribution": true, "human_time_change": null, "human_time_estimate": null, "human_total_time_spent": null, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "last_commit": { "id": "36b5813240a9d2daa29b05046d56a53e18f39a3e", "message": "Some ned more AAAA\n", "title": "Some ned more AAAA", "timestamp": "2025-07-29T16:45:02+02:00", "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/commit/36b5813240a9d2daa29b05046d56a53e18f39a3e", "author": { "name": "6543", "email": "[REDACTED]" } }, "reviewer_ids": [29352668], "source": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "state": "opened", "target": { "id": 72081820, "name": "test_ci_tmp", "description": null, "web_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "avatar_url": null, "git_ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "git_http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git", "namespace": "demoaccount2-commits-group", "visibility_level": 0, "path_with_namespace": "demoaccount2-commits-group/test_ci_tmp", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "ssh_url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "http_url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp.git" }, "time_change": 0, "total_time_spent": 0, "url": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp/-/merge_requests/1", "work_in_progress": false, "approval_rules": [], "action": "action_we_do_not_support" }, "labels": [ { "id": 41869663, "title": "documentation", "color": "#0075ca", "project_id": 72081820, "created_at": "2025-07-30 00:40:00 UTC", "updated_at": "2025-07-30 00:40:00 UTC", "template": false, "description": null, "type": "ProjectLabel", "group_id": null } ], "changes": {}, "repository": { "name": "test_ci_tmp", "url": "git@gitlab.com:demoaccount2-commits-group/test_ci_tmp.git", "description": null, "homepage": "https://gitlab.com/demoaccount2-commits-group/test_ci_tmp" }, "reviewers": [ { "id": 29352668, "name": "Placeholder github Source User", "username": "demoaccount2commits_placeholder_6s82rp", "avatar_url": "https://secure.gravatar.com/avatar/6d8b40c6bd417e69e359e712b55471dfc72af88c732fed9fe8d276a210aa5dd8?s=80&d=identicon", "email": "[REDACTED]" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestUpdated.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "some@mail.info" }, "project": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "object_attributes": { "assignee_id": 2251488, "author_id": 2251488, "created_at": "2022-01-10 15:23:41 UTC", "description": "", "head_pipeline_id": 449733536, "id": 134400602, "iid": 3, "last_edited_at": "2022-01-17 15:46:23 UTC", "last_edited_by_id": 2251488, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "anbraten-main-patch-05373", "source_project_id": 32059612, "state_id": 1, "target_branch": "main", "target_project_id": 32059612, "time_estimate": 0, "title": "Update client.go 🎉", "updated_at": "2022-01-17 15:47:39 UTC", "updated_by_id": 2251488, "url": "https://gitlab.com/anbraten/woodpecker/-/merge_requests/3", "source": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "target": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "last_commit": { "id": "c136499ec574e1034b24c5d306de9acda3005367", "message": "Update folder/todo.txt", "title": "Update folder/todo.txt", "timestamp": "2022-01-17T15:47:38+00:00", "url": "https://gitlab.com/anbraten/woodpecker/-/commit/c136499ec574e1034b24c5d306de9acda3005367", "author": { "name": "Anbraten", "email": "some@mail.info" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [2251488], "state": "opened", "blocking_discussions_resolved": true, "action": "update", "oldrev": "8b641937b7340066d882b9d8a8cc5b0573a207de" }, "labels": [], "changes": { "updated_at": { "previous": "2022-01-17 15:46:23 UTC", "current": "2022-01-17 15:47:39 UTC" } }, "repository": { "name": "woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "description": "", "homepage": "https://gitlab.com/anbraten/woodpecker" }, "assignees": [ { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "some@mail.info" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPullRequestWithoutChanges.json ================================================ { "object_kind": "merge_request", "event_type": "merge_request", "user": { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "some@mail.info" }, "project": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "object_attributes": { "assignee_id": 2251488, "author_id": 2251488, "created_at": "2022-01-10 15:23:41 UTC", "description": "", "head_pipeline_id": 449733536, "id": 134400602, "iid": 3, "last_edited_at": "2022-01-17 15:46:23 UTC", "last_edited_by_id": 2251488, "merge_commit_sha": null, "merge_error": null, "merge_params": { "force_remove_source_branch": "1" }, "merge_status": "unchecked", "merge_user_id": null, "merge_when_pipeline_succeeds": false, "milestone_id": null, "source_branch": "anbraten-main-patch-05373", "source_project_id": 32059612, "state_id": 1, "target_branch": "main", "target_project_id": 32059612, "time_estimate": 0, "title": "Update client.go 🎉", "updated_at": "2022-01-17 15:47:39 UTC", "updated_by_id": 2251488, "url": "https://gitlab.com/anbraten/woodpecker/-/merge_requests/3", "source": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "target": { "id": 32059612, "name": "woodpecker", "description": "", "web_url": "https://gitlab.com/anbraten/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "git_http_url": "https://gitlab.com/anbraten/woodpecker.git", "namespace": "Anbraten", "visibility_level": 20, "path_with_namespace": "anbraten/woodpecker", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbraten/woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "ssh_url": "git@gitlab.com:anbraten/woodpecker.git", "http_url": "https://gitlab.com/anbraten/woodpecker.git" }, "last_commit": { "id": "c136499ec574e1034b24c5d306de9acda3005367", "message": "Update folder/todo.txt", "title": "Update folder/todo.txt", "timestamp": "2022-01-17T15:47:38+00:00", "url": "https://gitlab.com/anbraten/woodpecker/-/commit/c136499ec574e1034b24c5d306de9acda3005367", "author": { "name": "Anbraten", "email": "some@mail.info" } }, "work_in_progress": false, "total_time_spent": 0, "time_change": 0, "human_total_time_spent": null, "human_time_change": null, "human_time_estimate": null, "assignee_ids": [2251488], "state": "opened", "blocking_discussions_resolved": true, "action": "update" }, "labels": [], "changes": { "updated_at": { "previous": "2022-01-17 15:46:23 UTC", "current": "2022-01-17 15:47:39 UTC" } }, "repository": { "name": "woodpecker", "url": "git@gitlab.com:anbraten/woodpecker.git", "description": "", "homepage": "https://gitlab.com/anbraten/woodpecker" }, "assignees": [ { "id": 2251488, "name": "Anbraten", "username": "anbraten", "avatar_url": "https://secure.gravatar.com/avatar/fc9b6fe77c6b732a02925a62a81f05a0?s=80&d=identicon", "email": "some@mail.info" } ] } ================================================ FILE: server/forge/gitlab/fixtures/HookPush.json ================================================ { "object_kind": "push", "event_name": "push", "before": "ffe8eb4f91d1fe6bc49f1e610e50e4b5767f0104", "after": "16862e368d8ab812e48833b741dad720d6e2cb7f", "ref": "refs/heads/main", "checkout_sha": "16862e368d8ab812e48833b741dad720d6e2cb7f", "message": null, "user_id": 2, "user_name": "the test", "user_username": "test", "user_email": "", "user_avatar": "https://www.gravatar.com/avatar/dd46a756faad4727fb679320751f6dea?s=80&d=identicon", "project_id": 2, "project": { "id": 2, "name": "Woodpecker", "description": "", "web_url": "http://10.40.8.5:3200/test/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@10.40.8.5:test/woodpecker.git", "git_http_url": "http://10.40.8.5:3200/test/woodpecker.git", "namespace": "the test", "visibility_level": 20, "path_with_namespace": "test/woodpecker", "default_branch": "develop", "ci_config_path": null, "homepage": "http://10.40.8.5:3200/test/woodpecker", "url": "git@10.40.8.5:test/woodpecker.git", "ssh_url": "git@10.40.8.5:test/woodpecker.git", "http_url": "http://10.40.8.5:3200/test/woodpecker.git" }, "commits": [ { "id": "16862e368d8ab812e48833b741dad720d6e2cb7f", "message": "Update main.go", "title": "Update main.go", "timestamp": "2021-09-27T04:46:14+00:00", "url": "http://10.40.8.5:3200/test/woodpecker/-/commit/16862e368d8ab812e48833b741dad720d6e2cb7f", "author": { "name": "the test", "email": "test@test.test" }, "added": [], "modified": ["cmd/cli/main.go"], "removed": [] } ], "total_commits_count": 1, "push_options": {}, "repository": { "name": "Woodpecker", "url": "git@10.40.8.5:test/woodpecker.git", "description": "", "homepage": "http://10.40.8.5:3200/test/woodpecker", "git_http_url": "http://10.40.8.5:3200/test/woodpecker.git", "git_ssh_url": "git@10.40.8.5:test/woodpecker.git", "visibility_level": 20 } } ================================================ FILE: server/forge/gitlab/fixtures/HookTag.json ================================================ { "object_kind": "tag_push", "event_name": "tag_push", "before": "0000000000000000000000000000000000000000", "after": "fabed3d94cd03e6c2b7958afa9569c18a24d301f", "ref": "refs/tags/v22", "checkout_sha": "16862e368d8ab812e48833b741dad720d6e2cb7f", "message": "hi", "user_id": 2, "user_name": "the test", "user_username": "test", "user_email": "", "user_avatar": "https://www.gravatar.com/avatar/dd46a756faad4727fb679320751f6dea?s=80&d=identicon", "project_id": 2, "project": { "id": 2, "name": "Woodpecker", "description": "", "web_url": "http://10.40.8.5:3200/test/woodpecker", "avatar_url": "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", "git_ssh_url": "git@10.40.8.5:test/woodpecker.git", "git_http_url": "http://10.40.8.5:3200/test/woodpecker.git", "namespace": "the test", "visibility_level": 20, "path_with_namespace": "test/woodpecker", "default_branch": "develop", "ci_config_path": null, "homepage": "http://10.40.8.5:3200/test/woodpecker", "url": "git@10.40.8.5:test/woodpecker.git", "ssh_url": "git@10.40.8.5:test/woodpecker.git", "http_url": "http://10.40.8.5:3200/test/woodpecker.git" }, "commits": [ { "id": "16862e368d8ab812e48833b741dad720d6e2cb7f", "message": "Update main.go", "title": "Update main.go", "timestamp": "2021-09-27T04:46:14+00:00", "url": "http://10.40.8.5:3200/test/woodpecker/-/commit/16862e368d8ab812e48833b741dad720d6e2cb7f", "author": { "name": "the test", "email": "test@test.test" }, "added": [], "modified": ["cmd/cli/main.go"], "removed": [] } ], "total_commits_count": 1, "push_options": {}, "repository": { "name": "Woodpecker", "url": "git@10.40.8.5:test/woodpecker.git", "description": "", "homepage": "http://10.40.8.5:3200/test/woodpecker", "git_http_url": "http://10.40.8.5:3200/test/woodpecker.git", "git_ssh_url": "git@10.40.8.5:test/woodpecker.git", "visibility_level": 20 } } ================================================ FILE: server/forge/gitlab/fixtures/WebhookReleaseBody.json ================================================ { "id": 4268085, "created_at": "2022-02-09 20:19:09 UTC", "description": "new version desc", "name": "Awesome version 0.0.2", "released_at": "2022-02-09 20:19:09 UTC", "tag": "0.0.2", "object_kind": "release", "project": { "id": 32521798, "name": "ci", "description": "", "web_url": "https://gitlab.com/anbratens-test/ci", "avatar_url": null, "git_ssh_url": "git@gitlab.com:anbratens-test/ci.git", "git_http_url": "https://gitlab.com/anbratens-test/ci.git", "namespace": "anbratens-test", "visibility_level": 0, "path_with_namespace": "anbratens-test/ci", "default_branch": "main", "ci_config_path": "", "homepage": "https://gitlab.com/anbratens-test/ci", "url": "git@gitlab.com:anbratens-test/ci.git", "ssh_url": "git@gitlab.com:anbratens-test/ci.git", "http_url": "https://gitlab.com/anbratens-test/ci.git" }, "url": "https://gitlab.com/anbratens-test/ci/-/releases/0.0.2", "action": "create", "assets": { "count": 4, "links": [], "sources": [ { "format": "zip", "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.zip" }, { "format": "tar.gz", "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar.gz" }, { "format": "tar.bz2", "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar.bz2" }, { "format": "tar", "url": "https://gitlab.com/anbratens-test/ci/-/archive/0.0.2/ci-0.0.2.tar" } ] }, "commit": { "id": "0b8c02955ba445ea70d22824d9589678852e2b93", "message": "Initial commit", "title": "Initial commit", "timestamp": "2022-01-03T10:39:51+00:00", "url": "https://gitlab.com/anbratens-test/ci/-/commit/0b8c02955ba445ea70d22824d9589678852e2b93", "author": { "name": "Anbraten", "email": "2251488-anbraten@users.noreply.gitlab.com" } } } ================================================ FILE: server/forge/gitlab/fixtures/hooks.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( _ "embed" "net/http" "net/url" ) var ( ServiceHookMethod = http.MethodPost ServiceHookURL, _ = url.Parse( "http://10.40.8.5:8000/hook?owner=test&name=woodpecker&access_token=dummyToken." + "eyJ0ZXh0IjoidGVzdC93b29kcGVja2VyIiwidHlwZSI6Imhvb2sifQ.x3kPnmZtxZQ_9_eMhfQ1HSmj_SLhdT_Lu2hMczWjKh0") ServiceHookHeaders = http.Header{ "Content-Type": []string{"application/json"}, "User-Agent": []string{"GitLab/14.3.0"}, "X-Gitlab-Event": []string{"Service Hook"}, } ReleaseHookHeaders = http.Header{ "Content-Type": []string{"application/json"}, "User-Agent": []string{"GitLab/14.3.0"}, "X-Gitlab-Event": []string{"Release Hook"}, } MergeRequestHookHeaders = http.Header{ "Content-Type": []string{"application/json"}, "User-Agent": []string{"GitLab/18.3.0-pre"}, "X-Gitlab-Event": []string{"Merge Request Hook"}, } ) // HookPush is payload of a push event // //go:embed HookPush.json var HookPush []byte // HookTag is payload of a TAG event // //go:embed HookTag.json var HookTag []byte // HookPullRequest is payload of a PULL_REQUEST event // //go:embed HookPullRequestUpdated.json var HookPullRequestUpdated []byte //go:embed HookPullRequestOpened.json var HookPullRequestOpened []byte //go:embed HookPullRequestWithoutChanges.json var HookPullRequestWithoutChanges []byte //go:embed HookPullRequestApproved.json var HookPullRequestApproved []byte //go:embed HookPullRequestEdited.json var HookPullRequestEdited []byte //go:embed HookPullRequestClosed.json var HookPullRequestClosed []byte //go:embed HookPullRequestMerged.json var HookPullRequestMerged []byte //go:embed WebhookReleaseBody.json var WebhookReleaseBody []byte //go:embed HookPullRequestReopened.json var HookPullRequestReopened []byte //go:embed HookPullRequestUnsupportedAction.json var HookPullRequestUnsupportedAction []byte //go:embed HookPullRequestReviewRequested.json var HookPullRequestReviewRequested []byte //go:embed HookPullRequestReviewRequestDel.json var HookPullRequestReviewRequestDel []byte //go:embed HookPullRequestAssigned.json var HookPullRequestAssigned []byte //go:embed HookPullRequestDemilestoned.json var HookPullRequestDemilestoned []byte //go:embed HookPullRequestLabelsAdded.json var HookPullRequestLabelsAdded []byte //go:embed HookPullRequestLabelsCleared.json var HookPullRequestLabelsCleared []byte //go:embed HookPullRequestLabelsUpdated.json var HookPullRequestLabelsUpdated []byte //go:embed HookPullRequestMilestoned.json var HookPullRequestMilestoned []byte //go:embed HookPullRequestUnapproved.json var HookPullRequestUnapproved []byte //go:embed HookPullRequestUnassigned.json var HookPullRequestUnassigned []byte ================================================ FILE: server/forge/gitlab/fixtures/oauth.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures var accessTokenPayload = []byte(`access_token=sekret&scope=api&token_type=bearer`) ================================================ FILE: server/forge/gitlab/fixtures/projects.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures // sample repository list. var allProjectsPayload = []byte(` [ { "id": 4, "description": null, "default_branch": "main", "public": false, "visibility": "private", "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", "web_url": "http://example.com/diaspora/diaspora-client", "owner": { "id": 3, "name": "Diaspora", "username": "some_user", "created_at": "2013-09-30T13:46:02Z" }, "name": "Diaspora Client", "name_with_namespace": "Diaspora / Diaspora Client", "path": "diaspora-client", "path_with_namespace": "diaspora/diaspora-client", "issues_enabled": true, "merge_requests_enabled": true, "wiki_enabled": true, "snippets_enabled": false, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "namespace": { "created_at": "2013-09-30T13:46:02Z", "description": "", "id": 3, "name": "Diaspora", "owner_id": 1, "path": "diaspora", "updated_at": "2013-09-30T13:46:02Z" }, "archived": false, "permissions": { "project_access": { "access_level": 10, "notification_level": 3 }, "group_access": { "access_level": 50, "notification_level": 3 } } }, { "id": 6, "description": null, "default_branch": "main", "public": false, "visibility": "private", "ssh_url_to_repo": "git@example.com:brightbox/puppet.git", "http_url_to_repo": "http://example.com/brightbox/puppet.git", "web_url": "http://example.com/brightbox/puppet", "owner": { "id": 1, "name": "Brightbox", "username": "test_user", "created_at": "2013-09-30T13:46:02Z" }, "name": "Puppet", "name_with_namespace": "Brightbox / Puppet", "path": "puppet", "path_with_namespace": "brightbox/puppet", "issues_enabled": true, "merge_requests_enabled": true, "wiki_enabled": true, "snippets_enabled": false, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "namespace": { "created_at": "2013-09-30T13:46:02Z", "description": "", "id": 4, "name": "Brightbox", "owner_id": 1, "path": "brightbox", "updated_at": "2013-09-30T13:46:02Z" }, "archived": true, "permissions": { "project_access": { "access_level": 10, "notification_level": 3 }, "group_access": { "access_level": 50, "notification_level": 3 } } } ] `) var notArchivedProjectsPayload = []byte(` [ { "id": 4, "description": null, "default_branch": "main", "public": false, "visibility": "private", "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", "web_url": "http://example.com/diaspora/diaspora-client", "owner": { "id": 3, "name": "Diaspora", "username": "some_user", "created_at": "2013-09-30T13:46:02Z" }, "name": "Diaspora Client", "name_with_namespace": "Diaspora / Diaspora Client", "path": "diaspora-client", "path_with_namespace": "diaspora/diaspora-client", "issues_enabled": true, "merge_requests_enabled": true, "wiki_enabled": true, "snippets_enabled": false, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "namespace": { "created_at": "2013-09-30T13:46:02Z", "description": "", "id": 3, "name": "Diaspora", "owner_id": 1, "path": "diaspora", "updated_at": "2013-09-30T13:46:02Z" }, "archived": false, "permissions": { "project_access": { "access_level": 10, "notification_level": 3 }, "group_access": { "access_level": 50, "notification_level": 3 } } } ] `) var project4Payload = []byte(` { "id": 4, "description": null, "default_branch": "main", "public": false, "visibility": "private", "ssh_url_to_repo": "git@example.com:diaspora/diaspora-client.git", "http_url_to_repo": "http://example.com/diaspora/diaspora-client.git", "web_url": "http://example.com/diaspora/diaspora-client", "owner": { "id": 3, "name": "Diaspora", "username": "some_user", "created_at": "2013-09-30T13:46:02Z" }, "name": "Diaspora Client", "name_with_namespace": "Diaspora / Diaspora Client", "path": "diaspora-client", "path_with_namespace": "diaspora/diaspora-client", "issues_enabled": true, "merge_requests_enabled": true, "wiki_enabled": true, "snippets_enabled": false, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "namespace": { "created_at": "2013-09-30T13:46:02Z", "description": "", "id": 3, "name": "Diaspora", "owner_id": 1, "path": "diaspora", "updated_at": "2013-09-30T13:46:02Z" }, "archived": false, "permissions": { "project_access": { "access_level": 10, "notification_level": 3 }, "group_access": { "access_level": 50, "notification_level": 3 } } } `) var project6Payload = []byte(` { "id": 6, "description": null, "default_branch": "main", "public": false, "visibility": "private", "ssh_url_to_repo": "git@example.com:brightbox/puppet.git", "http_url_to_repo": "http://example.com/brightbox/puppet.git", "web_url": "http://example.com/brightbox/puppet", "owner": { "id": 1, "name": "Brightbox", "username": "test_user", "created_at": "2013-09-30T13:46:02Z" }, "name": "Puppet", "name_with_namespace": "Brightbox / Puppet", "path": "puppet", "path_with_namespace": "brightbox/puppet", "issues_enabled": true, "merge_requests_enabled": true, "wiki_enabled": true, "snippets_enabled": false, "created_at": "2013-09-30T13:46:02Z", "last_activity_at": "2013-09-30T13:46:02Z", "namespace": { "created_at": "2013-09-30T13:46:02Z", "description": "", "id": 4, "name": "Brightbox", "owner_id": 1, "path": "brightbox", "updated_at": "2013-09-30T13:46:02Z" }, "archived": false, "permissions": { "project_access": null, "group_access": null } } `) var project4PayloadHook = []byte(` { "id": 10717088, "url": "http://example.com/api/hook", "created_at": "2021-12-18T23:29:33.852Z", "push_events": true, "tag_push_events": true, "merge_requests_events": true, "repository_update_events": false, "enable_ssl_verification": true, "project_id": 4, "issues_events": false, "confidential_issues_events": false, "note_events": false, "confidential_note_events": null, "pipeline_events": false, "wiki_page_events": false, "deployment_events": true, "job_events": false, "releases_events": false, "push_events_branch_filter": null } `) var project4PayloadHooks = []byte(` [ { "id": 10717088, "url": "http://example.com/api/hook", "created_at": "2021-12-18T23:29:33.852Z", "push_events": true, "tag_push_events": true, "merge_requests_events": true, "repository_update_events": false, "enable_ssl_verification": true, "project_id": 4, "issues_events": false, "confidential_issues_events": false, "note_events": false, "confidential_note_events": null, "pipeline_events": false, "wiki_page_events": false, "deployment_events": true, "job_events": false, "releases_events": false, "push_events_branch_filter": null } ] `) var project4PayloadMembers = []byte(` { "id": 3, "username": "some_user", "name": "Diaspora", "state": "active", "locked": false, "avatar_url": "https://example.com/uploads/-/system/user/avatar/3/avatar.png", "web_url": "https://example.com/some_user", "access_level": 50, "created_at": "2024-01-16T12:39:58.912Z", "expires_at": null } `) var project6PayloadMembers = []byte(` { "id": 3, "username": "some_user", "name": "Diaspora", "state": "active", "locked": false, "avatar_url": "https://example.com/uploads/-/system/user/avatar/3/avatar.png", "web_url": "https://example.com/some_user", "access_level": 30, "created_at": "2024-01-16T12:39:58.912Z", "expires_at": null } `) ================================================ FILE: server/forge/gitlab/fixtures/testdata.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures import ( "net/http" "net/http/httptest" "testing" ) // NewServer setup a mock server for testing purposes. func NewServer(t *testing.T) *httptest.Server { mux := http.NewServeMux() server := httptest.NewServer(mux) // handle requests and serve mock data mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { t.Logf("gitlab forge mock server: [%s] %s", r.Method, r.URL.Path) // evaluate the path to serve a dummy data file // TODO: find source of "/api/v4/" requests // assert.EqualValues(t, "go-gitlab", r.Header.Get("user-agent"), "on request: "+r.URL.Path) switch r.URL.Path { case "/api/v4/projects": if r.FormValue("archived") == "false" { _, _ = w.Write(notArchivedProjectsPayload) } else { _, _ = w.Write(allProjectsPayload) } return case "/api/v4/projects/diaspora/diaspora-client": _, _ = w.Write(project4Payload) return case "/api/v4/projects/brightbox/puppet": case "/api/v4/projects/6": _, _ = w.Write(project6Payload) return case "/api/v4/projects/4/hooks": switch r.Method { case http.MethodGet: _, _ = w.Write(project4PayloadHooks) case http.MethodPost: _, _ = w.Write(project4PayloadHook) w.WriteHeader(201) } return case "/api/v4/projects/4/hooks/10717088": w.WriteHeader(201) return case "/api/v4/projects/4/members/all/3": _, _ = w.Write(project4PayloadMembers) return case "/api/v4/projects/diaspora/diaspora-client/members/all/3": _, _ = w.Write(project4PayloadMembers) return case "/api/v4/projects/6/members/all/3": _, _ = w.Write(project6PayloadMembers) return case "/oauth/token": _, _ = w.Write(accessTokenPayload) return case "/api/v4/user": _, _ = w.Write(currentUserPayload) return } // else return a 404 http.NotFound(w, r) }) // return the server to the client which // will need to know the base URL path return server } ================================================ FILE: server/forge/gitlab/fixtures/users.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package fixtures var currentUserPayload = []byte(` { "id": 1, "username": "john_smith", "email": "john@example.com", "name": "John Smith", "private_token": "dd34asd13as", "state": "active", "created_at": "2012-05-23T08:00:58Z", "bio": null, "skype": "", "linkedin": "", "twitter": "", "website_url": "", "theme_id": 1, "color_scheme_id": 2, "is_admin": false, "can_create_group": true, "can_create_project": true, "projects_limit": 100 } `) ================================================ FILE: server/forge/gitlab/gitlab.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitlab import ( "context" "crypto/tls" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/rs/zerolog/log" gitlab "gitlab.com/gitlab-org/api/client-go/v2" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/common" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) const ( defaultScope = "api" defaultPerPage = 100 ) // Opts defines configuration options. type Opts struct { URL string // Gitlab server url. OAuthClientID string // Oauth2 client id. OAuthClientSecret string // Oauth2 client secret. SkipVerify bool // Skip ssl verification. OAuthHost string // Public url for oauth if different from url. } // Gitlab implements "Forge" interface. type GitLab struct { id int64 url string oAuthClientID string oAuthClientSecret string skipVerify bool hideArchives bool search bool oAuthHost string } // New returns a Forge implementation that integrates with Gitlab, an open // source Git service. See https://gitlab.com func New(id int64, opts Opts) (forge.Forge, error) { return &GitLab{ id: id, url: opts.URL, oAuthClientID: opts.OAuthClientID, oAuthClientSecret: opts.OAuthClientSecret, oAuthHost: opts.OAuthHost, skipVerify: opts.SkipVerify, hideArchives: true, }, nil } // Name returns the string name of this driver. func (g *GitLab) Name() string { return "gitlab" } // URL returns the root url of a configured forge. func (g *GitLab) URL() string { return g.url } func (g *GitLab) oauth2Config(ctx context.Context) (*oauth2.Config, context.Context) { publicOAuthURL := g.oAuthHost if publicOAuthURL == "" { publicOAuthURL = g.url } return &oauth2.Config{ ClientID: g.oAuthClientID, ClientSecret: g.oAuthClientSecret, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("%s/oauth/authorize", publicOAuthURL), TokenURL: fmt.Sprintf("%s/oauth/token", g.url), }, Scopes: []string{defaultScope}, RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost), }, context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: g.skipVerify}, Proxy: http.ProxyFromEnvironment, }}) } // Login authenticates the session and returns the // forge user details. func (g *GitLab) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) { config, oauth2Ctx := g.oauth2Config(ctx) redirectURL := config.AuthCodeURL(req.State) // check the OAuth code if len(req.Code) == 0 { return nil, redirectURL, nil } token, err := config.Exchange(oauth2Ctx, req.Code) if err != nil { return nil, redirectURL, fmt.Errorf("error exchanging token: %w", err) } client, err := newClient(g.url, token.AccessToken, g.skipVerify) if err != nil { return nil, redirectURL, err } login, _, err := client.Users.CurrentUser(gitlab.WithContext(ctx)) if err != nil { return nil, redirectURL, err } user := &model.User{ Login: login.Username, Email: login.Email, Avatar: login.AvatarURL, ForgeRemoteID: model.ForgeRemoteID(fmt.Sprint(login.ID)), AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, Expiry: token.Expiry.UTC().Unix(), } if !strings.HasPrefix(user.Avatar, "http") { user.Avatar = g.url + "/" + login.AvatarURL } return user, redirectURL, nil } // Refresh refreshes the Gitlab oauth2 access token. If the token is // refreshed the user is updated and a true value is returned. func (g *GitLab) Refresh(ctx context.Context, user *model.User) (bool, error) { config, oauth2Ctx := g.oauth2Config(ctx) config.RedirectURL = "" source := config.TokenSource(oauth2Ctx, &oauth2.Token{ AccessToken: user.AccessToken, RefreshToken: user.RefreshToken, Expiry: time.Unix(user.Expiry, 0), }) token, err := source.Token() if err != nil || len(token.AccessToken) == 0 { return false, err } user.AccessToken = token.AccessToken user.RefreshToken = token.RefreshToken user.Expiry = token.Expiry.UTC().Unix() return true, nil } // Teams fetches a list of team memberships from the forge. func (g *GitLab) Teams(ctx context.Context, user *model.User, p *model.ListOptions) ([]*model.Team, error) { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } perPage := min(p.PerPage, defaultPerPage) groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{ ListOptions: gitlab.ListOptions{ Page: int64(p.Page), PerPage: int64(perPage), }, AllAvailable: gitlab.Ptr(false), MinAccessLevel: gitlab.Ptr(gitlab.DeveloperPermissions), // TODO: check what's best here }, gitlab.WithContext(ctx)) if err != nil { return nil, err } teams := make([]*model.Team, 0, len(groups)) for i := range groups { teams = append(teams, &model.Team{ Login: groups[i].Name, Avatar: groups[i].AvatarURL, }, ) } return teams, nil } // getProject fetches the named repository from the forge. func (g *GitLab) getProject(ctx context.Context, client *gitlab.Client, forgeRemoteID model.ForgeRemoteID, owner, name string) (*gitlab.Project, error) { var ( repo *gitlab.Project err error ) if forgeRemoteID.IsValid() { intID, err := strconv.Atoi(string(forgeRemoteID)) if err != nil { return nil, err } repo, resp, err := client.Projects.GetProject(intID, nil, gitlab.WithContext(ctx)) if err != nil && resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return repo, err } repo, resp, err := client.Projects.GetProject(fmt.Sprintf("%s/%s", owner, name), nil, gitlab.WithContext(ctx)) if err != nil && resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, forge_types.ErrRepoNotFound) } return repo, err } func (g *GitLab) getInheritedProjectMember(ctx context.Context, client *gitlab.Client, forgeRemoteID model.ForgeRemoteID, owner, name string, userID int64) (*gitlab.ProjectMember, error) { if forgeRemoteID.IsValid() { intID, err := strconv.Atoi(string(forgeRemoteID)) if err != nil { return nil, err } projectMember, _, err := client.ProjectMembers.GetInheritedProjectMember(intID, userID, gitlab.WithContext(ctx)) return projectMember, err } projectMember, _, err := client.ProjectMembers.GetInheritedProjectMember(fmt.Sprintf("%s/%s", owner, name), userID, gitlab.WithContext(ctx)) return projectMember, err } // Repo fetches the repository from the forge. func (g *GitLab) Repo(ctx context.Context, user *model.User, remoteID model.ForgeRemoteID, owner, name string) (*model.Repo, error) { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, remoteID, owner, name) if err != nil { return nil, err } intUserID, err := strconv.Atoi(string(user.ForgeRemoteID)) if err != nil { return nil, err } projectMember, err := g.getInheritedProjectMember(ctx, client, remoteID, owner, name, int64(intUserID)) if err != nil { return nil, err } return g.convertGitLabRepo(_repo, projectMember) } // Repos fetches a list of repos from the forge. func (g *GitLab) Repos(ctx context.Context, user *model.User, p *model.ListOptions) ([]*model.Repo, error) { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } perPage := min(p.PerPage, defaultPerPage) opts := &gitlab.ListProjectsOptions{ ListOptions: gitlab.ListOptions{ Page: int64(p.Page), PerPage: int64(perPage), }, MinAccessLevel: gitlab.Ptr(gitlab.DeveloperPermissions), // TODO: check what's best here } if g.hideArchives { opts.Archived = gitlab.Ptr(false) } intUserID, err := strconv.Atoi(string(user.ForgeRemoteID)) if err != nil { return nil, err } projects, _, err := client.Projects.ListProjects(opts, gitlab.WithContext(ctx)) if err != nil { return nil, err } repos := make([]*model.Repo, 0, len(projects)) for i := range projects { projectMember, _, err := client.ProjectMembers.GetInheritedProjectMember(projects[i].ID, int64(intUserID), gitlab.WithContext(ctx)) if err != nil { return nil, err } repo, err := g.convertGitLabRepo(projects[i], projectMember) if err != nil { return nil, err } repos = append(repos, repo) } return repos, err } func (g *GitLab) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { token := common.UserToken(ctx, r, u) client, err := newClient(g.url, token, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, r.ForgeRemoteID, r.Owner, r.Name) if err != nil { return nil, err } state := "opened" pullRequests, _, err := client.MergeRequests.ListProjectMergeRequests(_repo.ID, &gitlab.ListProjectMergeRequestsOptions{ ListOptions: gitlab.ListOptions{Page: int64(p.Page), PerPage: int64(p.PerPage)}, State: &state, }) if err != nil { return nil, err } result := make([]*model.PullRequest, len(pullRequests)) for i := range pullRequests { result[i] = &model.PullRequest{ Index: model.ForgeRemoteID(strconv.Itoa(int(pullRequests[i].ID))), Title: pullRequests[i].Title, } } return result, err } // File fetches a file from the forge repository and returns in string format. func (g *GitLab) File(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, fileName string) ([]byte, error) { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return nil, err } file, resp, err := client.RepositoryFiles.GetRawFile(_repo.ID, fileName, &gitlab.GetRawFileOptions{Ref: &pipeline.Commit}, gitlab.WithContext(ctx)) if resp != nil && resp.StatusCode == http.StatusNotFound { return nil, errors.Join(err, &forge_types.ErrConfigNotFound{Configs: []string{fileName}}) } return file, err } // Dir fetches a folder from the forge repository. func (g *GitLab) Dir(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, path string) ([]*forge_types.FileMeta, error) { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } files := make([]*forge_types.FileMeta, 0, defaultPerPage) _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return nil, err } opts := &gitlab.ListTreeOptions{ ListOptions: gitlab.ListOptions{PerPage: defaultPerPage}, Path: &path, Ref: &pipeline.Commit, Recursive: gitlab.Ptr(false), } for i := 1; true; i++ { opts.Page = 1 batch, _, err := client.Repositories.ListTree(_repo.ID, opts, gitlab.WithContext(ctx)) if err != nil { return nil, err } for i := range batch { if batch[i].Type != "blob" { // no file continue } data, err := g.File(ctx, user, repo, pipeline, batch[i].Path) if err != nil { if errors.Is(err, &forge_types.ErrConfigNotFound{}) { return nil, fmt.Errorf("git tree reported existence of file but we got: %s", err.Error()) } return nil, err } files = append(files, &forge_types.FileMeta{ Name: batch[i].Path, Data: data, }) } if len(batch) < defaultPerPage { break } } return files, nil } // Status sends the commit status back to gitlab. func (g *GitLab) Status(ctx context.Context, user *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return err } _, _, err = client.Commits.SetCommitStatus(_repo.ID, pipeline.Commit, &gitlab.SetCommitStatusOptions{ State: getStatus(workflow.State), Description: gitlab.Ptr(common.GetPipelineStatusDescription(workflow.State)), TargetURL: gitlab.Ptr(common.GetPipelineStatusURL(repo, pipeline, workflow)), Context: gitlab.Ptr(common.GetPipelineStatusContext(repo, pipeline, workflow)), }, gitlab.WithContext(ctx)) return err } // Netrc returns a netrc file capable of authenticating Gitlab requests and // cloning Gitlab repositories. The netrc will use the global machine account // when configured. func (g *GitLab) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { login := "" token := "" if u != nil { login = "oauth2" token = u.AccessToken } host, err := common.ExtractHostFromCloneURL(r.Clone) if err != nil { return nil, err } return &model.Netrc{ Login: login, Password: token, Machine: host, Type: model.ForgeTypeGitlab, }, nil } func (g *GitLab) getTokenAndWebURL(link string) (token, webURL string, err error) { uri, err := url.Parse(link) if err != nil { return "", "", err } token = uri.Query().Get("access_token") webURL = fmt.Sprintf("%s://%s/%s", uri.Scheme, uri.Host, strings.TrimPrefix(uri.Path, "/")) return token, webURL, nil } // Activate activates a repository by adding a Post-commit hook and // a Public Deploy key, if applicable. func (g *GitLab) Activate(ctx context.Context, user *model.User, repo *model.Repo, link string) error { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return err } token, webURL, err := g.getTokenAndWebURL(link) if err != nil { return err } if len(token) == 0 { return fmt.Errorf("no token found") } _, _, err = client.Projects.AddProjectHook(_repo.ID, &gitlab.AddProjectHookOptions{ URL: gitlab.Ptr(webURL), Token: gitlab.Ptr(token), PushEvents: gitlab.Ptr(true), TagPushEvents: gitlab.Ptr(true), MergeRequestsEvents: gitlab.Ptr(true), DeploymentEvents: gitlab.Ptr(true), EnableSSLVerification: gitlab.Ptr(!g.skipVerify), }, gitlab.WithContext(ctx)) return err } // Deactivate removes a repository by removing all the post-commit hooks // which are equal to link and removing the SSH deploy key. func (g *GitLab) Deactivate(ctx context.Context, user *model.User, repo *model.Repo, link string) error { client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return err } _, webURL, err := g.getTokenAndWebURL(link) if err != nil { return err } listProjectHooksOptions := &gitlab.ListProjectHooksOptions{ ListOptions: gitlab.ListOptions{ PerPage: defaultPerPage, Page: 1, }, } for { hooks, resp, err := client.Projects.ListProjectHooks(_repo.ID, listProjectHooksOptions, gitlab.WithContext(ctx)) if err != nil { return err } for _, hook := range hooks { if strings.Contains(hook.URL, webURL) { _, err = client.Projects.DeleteProjectHook(_repo.ID, hook.ID, gitlab.WithContext(ctx)) log.Info().Msg(fmt.Sprintf("successfully deleted hook with ID %d for repo %s", hook.ID, repo.FullName)) if err != nil { return err } } } if resp.CurrentPage >= resp.TotalPages { break } // Update the page number to get the next page listProjectHooksOptions.Page = resp.NextPage } return nil } // Branches returns the names of all branches for the named repository. func (g *GitLab) Branches(ctx context.Context, user *model.User, repo *model.Repo, p *model.ListOptions) ([]string, error) { token := common.UserToken(ctx, repo, user) client, err := newClient(g.url, token, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return nil, err } gitlabBranches, _, err := client.Branches.ListBranches(_repo.ID, &gitlab.ListBranchesOptions{ListOptions: gitlab.ListOptions{Page: int64(p.Page), PerPage: int64(p.PerPage)}}, gitlab.WithContext(ctx)) if err != nil { return nil, err } branches := make([]string, 0) for _, branch := range gitlabBranches { branches = append(branches, branch.Name) } return branches, nil } // BranchHead returns the sha of the head (latest commit) of the specified branch. func (g *GitLab) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { token := common.UserToken(ctx, r, u) client, err := newClient(g.url, token, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, r.ForgeRemoteID, r.Owner, r.Name) if err != nil { return nil, err } b, _, err := client.Branches.GetBranch(_repo.ID, branch, gitlab.WithContext(ctx)) if err != nil { return nil, err } return &model.Commit{ SHA: b.Commit.ID, ForgeURL: b.Commit.WebURL, }, nil } // Hook parses the post-commit hook from the Request body // and returns the required data in a standard format. func (g *GitLab) Hook(ctx context.Context, req *http.Request) (*model.Repo, *model.Pipeline, error) { defer req.Body.Close() payload, err := io.ReadAll(req.Body) if err != nil { return nil, nil, err } eventType := gitlab.WebhookEventType(req) parsed, err := gitlab.ParseWebhook(eventType, payload) if err != nil { return nil, nil, err } switch event := parsed.(type) { case *gitlab.MergeEvent: mergeID, milestoneID, repo, pipeline, err := convertMergeRequestHook(event, req) if err != nil { return nil, nil, err } if pipeline, err = g.loadMetadataFromMergeRequest(ctx, repo, pipeline, mergeID, milestoneID); err != nil { return nil, nil, err } return repo, pipeline, nil case *gitlab.PushEvent: if event.TotalCommitsCount == 0 { return nil, nil, &forge_types.ErrIgnoreEvent{Event: string(eventType), Reason: "no commits"} } return convertPushHook(event) case *gitlab.TagEvent: repo, pipeline, cmID, err := convertTagHook(event) if err != nil || pipeline.Message != "" { return repo, pipeline, err } // we have to fetch the commit message pipeline, err = g.loadCommitFromSHA(ctx, repo, pipeline, cmID) return repo, pipeline, err case *gitlab.ReleaseEvent: return convertReleaseHook(event) default: return nil, nil, &forge_types.ErrIgnoreEvent{Event: string(eventType)} } } // OrgMembership returns if user is member of organization and if user // is admin/owner in this organization. func (g *GitLab) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) { client, err := newClient(g.url, u.AccessToken, g.skipVerify) if err != nil { return nil, err } groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{ ListOptions: gitlab.ListOptions{ Page: 1, PerPage: defaultPerPage, }, Search: gitlab.Ptr(owner), }, gitlab.WithContext(ctx)) if err != nil { return nil, err } var gid int64 for _, group := range groups { if group.Name == owner { gid = group.ID break } } if gid == 0 { return &model.OrgPerm{}, nil } opts := &gitlab.ListGroupMembersOptions{ ListOptions: gitlab.ListOptions{ Page: 1, PerPage: defaultPerPage, }, } for i := 1; true; i++ { opts.Page = int64(i) members, _, err := client.Groups.ListAllGroupMembers(gid, opts, gitlab.WithContext(ctx)) if err != nil { return nil, err } for _, member := range members { if member.Username == u.Login { return &model.OrgPerm{Member: true, Admin: member.AccessLevel >= gitlab.OwnerPermissions}, nil } } if len(members) < int(opts.PerPage) { break } } return &model.OrgPerm{}, nil } func (g *GitLab) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) { client, err := newClient(g.url, u.AccessToken, g.skipVerify) if err != nil { return nil, err } users, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{ ListOptions: gitlab.ListOptions{ Page: 1, PerPage: 1, }, Username: gitlab.Ptr(owner), }) if len(users) == 1 && err == nil { return &model.Org{ Name: users[0].Username, IsUser: true, Private: users[0].PrivateProfile, }, nil } groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{ ListOptions: gitlab.ListOptions{ Page: 1, PerPage: defaultPerPage, }, Search: gitlab.Ptr(owner), }, gitlab.WithContext(ctx)) if err != nil { return nil, err } var matchedGroup *gitlab.Group for _, group := range groups { if group.FullPath == owner { matchedGroup = group break } } if matchedGroup == nil { return nil, fmt.Errorf("could not find org %s", owner) } return &model.Org{ Name: matchedGroup.FullPath, Private: matchedGroup.Visibility != gitlab.PublicVisibility, }, nil } func (g *GitLab) loadMetadataFromMergeRequest(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, mergeID, milestoneID int64) (*model.Pipeline, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return pipeline, nil } repo, err := _store.GetRepoNameFallback(g.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName) if err != nil { return nil, err } user, err := _store.GetUser(repo.UserID) if err != nil { return nil, err } forge.Refresh(ctx, g, _store, user) client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return nil, err } changes, _, err := client.MergeRequests.ListMergeRequestDiffs(_repo.ID, mergeID, &gitlab.ListMergeRequestDiffsOptions{}, gitlab.WithContext(ctx)) if err != nil { return nil, err } files := make([]string, 0, len(changes)*2) for _, file := range changes { files = append(files, file.NewPath, file.OldPath) } pipeline.ChangedFiles = utils.DeduplicateStrings(files) if milestoneID != 0 { milestone, _, err := client.Milestones.GetMilestone(_repo.ID, milestoneID) if err != nil { return nil, err } pipeline.PullRequestMilestone = milestone.Title } return pipeline, nil } func (g *GitLab) loadCommitFromSHA(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, sha string) (*model.Pipeline, error) { _store, ok := store.TryFromContext(ctx) if !ok { log.Error().Msg("could not get store from context") return pipeline, nil } repo, err := _store.GetRepoNameFallback(g.id, tmpRepo.ForgeRemoteID, tmpRepo.FullName) if err != nil { return nil, err } user, err := _store.GetUser(repo.UserID) if err != nil { return nil, err } forge.Refresh(ctx, g, _store, user) client, err := newClient(g.url, user.AccessToken, g.skipVerify) if err != nil { return nil, err } _repo, err := g.getProject(ctx, client, repo.ForgeRemoteID, repo.Owner, repo.Name) if err != nil { return nil, err } cm, _, err := client.Commits.GetCommit(_repo.ID, sha, &gitlab.GetCommitOptions{}, gitlab.WithContext(ctx)) if err != nil { return nil, err } pipeline.Author = cm.AuthorName pipeline.Email = cm.AuthorEmail pipeline.Message = cm.Message pipeline.Timestamp = cm.CommittedDate.Unix() if len(pipeline.Email) != 0 { pipeline.Avatar = getUserAvatar(pipeline.Email) } return pipeline, nil } ================================================ FILE: server/forge/gitlab/gitlab_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitlab import ( "bytes" "net/http" "net/url" "strconv" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitlab/fixtures" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func load(config string) *GitLab { _url, _ := url.Parse(config) params := _url.Query() _url.RawQuery = "" gitlab := GitLab{} gitlab.url = _url.String() gitlab.oAuthClientID = params.Get("client_id") gitlab.oAuthClientSecret = params.Get("client_secret") gitlab.skipVerify, _ = strconv.ParseBool(params.Get("skip_verify")) gitlab.hideArchives, _ = strconv.ParseBool(params.Get("hide_archives")) // this is a temp workaround gitlab.search, _ = strconv.ParseBool(params.Get("search")) return &gitlab } func Test_GitLab(t *testing.T) { // setup a dummy gitlab server server := fixtures.NewServer(t) defer server.Close() env := server.URL + "?client_id=test&client_secret=test" client := load(env) user := model.User{ Login: "test_user", AccessToken: "e3b0c44298fc1c149afbf4c8996fb", ForgeRemoteID: "3", } repo := model.Repo{ Name: "diaspora-client", Owner: "diaspora", } ctx := t.Context() // Test projects method t.Run("Should return only non-archived projects is hidden", func(t *testing.T) { client.hideArchives = true _projects, err := client.Repos(ctx, &user, &model.ListOptions{Page: 1, PerPage: 10}) assert.NoError(t, err) assert.Len(t, _projects, 1) }) t.Run("Should return all the projects", func(t *testing.T) { client.hideArchives = false _projects, err := client.Repos(ctx, &user, &model.ListOptions{Page: 1, PerPage: 10}) assert.NoError(t, err) assert.Len(t, _projects, 2) }) // Test repository method t.Run("Should return valid repo", func(t *testing.T) { _repo, err := client.Repo(ctx, &user, "0", "diaspora", "diaspora-client") assert.NoError(t, err) assert.Equal(t, "diaspora-client", _repo.Name) assert.Equal(t, "diaspora", _repo.Owner) assert.True(t, _repo.IsSCMPrivate) }) t.Run("Should return error, when repo not exist", func(t *testing.T) { _, err := client.Repo(ctx, &user, "0", "not-existed", "not-existed") assert.Error(t, err) }) t.Run("Should return repo with push access, when user inherits membership from namespace", func(t *testing.T) { _repo, err := client.Repo(ctx, &user, "6", "brightbox", "puppet") assert.NoError(t, err) assert.True(t, _repo.Perm.Push) }) // Test activate method t.Run("Activate, success", func(t *testing.T) { err := client.Activate(ctx, &user, &repo, "http://example.com/api/hook?access_token=token") assert.NoError(t, err) }) t.Run("Activate, failed no token", func(t *testing.T) { err := client.Activate(ctx, &user, &repo, "http://example.com/api/hook") assert.Error(t, err) }) // Test deactivate method t.Run("Deactivate", func(t *testing.T) { err := client.Deactivate(ctx, &user, &repo, "http://example.com/api/hook?access_token=token") assert.NoError(t, err) }) t.Run("test parse webhook", func(t *testing.T) { // Test hook method t.Run("parse push", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPush), ) req.Header = fixtures.ServiceHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, pipeline.Event, model.EventPush) assert.Equal(t, "test", hookRepo.Owner) assert.Equal(t, "woodpecker", hookRepo.Name) assert.Equal(t, "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", hookRepo.Avatar) assert.Equal(t, "develop", hookRepo.Branch) assert.Equal(t, "refs/heads/main", pipeline.Ref) assert.Equal(t, []string{"cmd/cli/main.go"}, pipeline.ChangedFiles) assert.Equal(t, model.EventPush, pipeline.Event) assert.Empty(t, pipeline.EventReason) } }) t.Run("tag push", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookTag), ) req.Header = fixtures.ServiceHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "test", hookRepo.Owner) assert.Equal(t, "woodpecker", hookRepo.Name) assert.Equal(t, "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", hookRepo.Avatar) assert.Equal(t, "develop", hookRepo.Branch) assert.Equal(t, "refs/tags/v22", pipeline.Ref) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventTag, pipeline.Event) assert.Empty(t, pipeline.EventReason) } }) t.Run("merge request", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestUpdated), ) req.Header = fixtures.ServiceHookHeaders // TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "http://example.com/uploads/project/avatar/555/Outh-20-Logo.jpg", hookRepo.Avatar) assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "anbraten", hookRepo.Owner) assert.Equal(t, "woodpecker", hookRepo.Name) assert.Equal(t, "Update client.go 🎉", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) // see L217 assert.Equal(t, model.EventPull, pipeline.Event) assert.Empty(t, pipeline.EventReason) } }) t.Run("merge request new opened", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestOpened), ) req.Header = fixtures.MergeRequestHookHeaders // TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Equal(t, "Edit README.md for more text to read", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) // see L217 assert.Equal(t, model.EventPull, pipeline.Event) assert.Empty(t, pipeline.EventReason) } }) t.Run("ignore merge request hook without changes", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestWithoutChanges), ) req.Header = fixtures.ServiceHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.Nil(t, hookRepo) assert.Nil(t, pipeline) if assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) { assert.EqualValues(t, "explicit ignored event 'Merge Request Hook', reason: Action 'update' no supported changes detected", err.Error()) } }) t.Run("ignore unsupported action", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestUnsupportedAction), ) req.Header = fixtures.ServiceHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.Nil(t, hookRepo) assert.Nil(t, pipeline) if assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) { assert.EqualValues(t, "explicit ignored event 'Merge Request Hook', reason: Action 'action_we_do_not_support' not supported", err.Error()) } }) t.Run("parse merge request closed", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestClosed), ) req.Header = fixtures.ServiceHookHeaders // TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "anbraten", hookRepo.Owner) assert.Equal(t, "woodpecker-test", hookRepo.Name) assert.Equal(t, "Add new file", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) // see L217 assert.Equal(t, model.EventPullClosed, pipeline.Event) } }) t.Run("merge request reopened", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestReopened), ) req.Header = fixtures.ServiceHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Equal(t, "Some ned more AAAA", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPull, pipeline.Event) } }) t.Run("parse merge request merged", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestMerged), ) req.Header = fixtures.ServiceHookHeaders // TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "anbraten", hookRepo.Owner) assert.Equal(t, "woodpecker-test", hookRepo.Name) assert.Equal(t, "Add new file", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) // see L217 assert.Equal(t, model.EventPullClosed, pipeline.Event) } }) t.Run("merge request title and description edited", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestEdited), ) req.Header = fixtures.MergeRequestHookHeaders // TODO: insert fake store into context to retrieve user & repo, this will activate fetching of ChangedFiles hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Equal(t, "Edit README for more text to read", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) // see L217 assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"title_edited", "description_edited"}, pipeline.EventReason) } }) t.Run("release", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.WebhookReleaseBody), ) req.Header = fixtures.ReleaseHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "refs/tags/0.0.2", pipeline.Ref) assert.Equal(t, "ci", hookRepo.Name) assert.Equal(t, "created release Awesome version 0.0.2", pipeline.Message) assert.Equal(t, model.EventRelease, pipeline.Event) } }) t.Run("merge request review approved", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestApproved), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) assert.Nil(t, hookRepo) assert.Nil(t, pipeline) }) t.Run("merge request review requested", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestReviewRequested), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Equal(t, "Edit README for more text to read", pipeline.Title) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"review_requested"}, pipeline.EventReason) } }) t.Run("merge request assigned", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestAssigned), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"assigned"}, pipeline.EventReason) } }) t.Run("merge request unassigned", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestUnassigned), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"assigned", "unassigned"}, pipeline.EventReason) } }) t.Run("merge request milestoned", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestMilestoned), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"milestoned"}, pipeline.EventReason) } }) t.Run("merge request demilestoned", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestDemilestoned), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"demilestoned"}, pipeline.EventReason) } }) t.Run("merge request labels added", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestLabelsAdded), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"label_added"}, pipeline.EventReason) } }) t.Run("merge request labels cleared", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestLabelsCleared), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"label_cleared"}, pipeline.EventReason) } }) t.Run("merge request labels updated", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestLabelsUpdated), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.NoError(t, err) if assert.NotNil(t, hookRepo) && assert.NotNil(t, pipeline) { assert.Equal(t, "main", hookRepo.Branch) assert.Equal(t, "demoaccount2-commits-group", hookRepo.Owner) assert.Equal(t, "test_ci_tmp", hookRepo.Name) assert.Len(t, pipeline.ChangedFiles, 0) assert.Equal(t, model.EventPullMetadata, pipeline.Event) assert.Equal(t, []string{"label_updated"}, pipeline.EventReason) } }) t.Run("merge request unapproved", func(t *testing.T) { req, _ := http.NewRequest( fixtures.ServiceHookMethod, fixtures.ServiceHookURL.String(), bytes.NewReader(fixtures.HookPullRequestUnapproved), ) req.Header = fixtures.MergeRequestHookHeaders hookRepo, pipeline, err := client.Hook(ctx, req) assert.ErrorIs(t, err, &types.ErrIgnoreEvent{}) assert.Nil(t, hookRepo) assert.Nil(t, pipeline) }) }) } func TestExtractFromPath(t *testing.T) { type testCase struct { name string input string wantOwner string wantName string errContains string } tests := []testCase{ { name: "basic two components", input: "owner/repo", wantOwner: "owner", wantName: "repo", }, { name: "three components", input: "owner/group/repo", wantOwner: "owner/group", wantName: "repo", }, { name: "many components", input: "owner/group/subgroup/deep/repo", wantOwner: "owner/group/subgroup/deep", wantName: "repo", }, { name: "empty string", input: "", errContains: "minimum match not found", }, { name: "single component", input: "onlyrepo", errContains: "minimum match not found", }, { name: "trailing slash", input: "owner/repo/", wantOwner: "owner/repo", wantName: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { owner, name, err := extractFromPath(tc.input) // Check error expectations if tc.errContains != "" { if assert.Error(t, err) { assert.Contains(t, err.Error(), tc.errContains) } return } assert.NoError(t, err) assert.EqualValues(t, tc.wantOwner, owner) assert.EqualValues(t, tc.wantName, name) }) } } ================================================ FILE: server/forge/gitlab/helper.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitlab import ( "crypto/tls" "net/http" gitlab "gitlab.com/gitlab-org/api/client-go/v2" "golang.org/x/oauth2" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" ) const ( gravatarBase = "https://www.gravatar.com/avatar" ) // newClient is a helper function that returns a new GitHub // client using the provided OAuth token. func newClient(url, accessToken string, skipVerify bool) (*gitlab.Client, error) { return gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{ TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), }, gitlab.WithBaseURL(url), gitlab.WithHTTPClient(&http.Client{ Transport: httputil.NewUserAgentRoundTripper( &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: skipVerify}, Proxy: http.ProxyFromEnvironment, }, "forge-gitlab"), })) } // isRead is a helper function that returns true if the // user has Read-only access to the repository. func isRead(proj *gitlab.Project, projectMember *gitlab.ProjectMember) bool { return proj.Visibility == gitlab.InternalVisibility || proj.Visibility == gitlab.PrivateVisibility || projectMember != nil && projectMember.AccessLevel >= gitlab.ReporterPermissions } // isWrite is a helper function that returns true if the // user has Read-Write access to the repository. func isWrite(projectMember *gitlab.ProjectMember) bool { return projectMember != nil && projectMember.AccessLevel >= gitlab.DeveloperPermissions } // isAdmin is a helper function that returns true if the // user has Admin access to the repository. func isAdmin(projectMember *gitlab.ProjectMember) bool { return projectMember != nil && projectMember.AccessLevel >= gitlab.MaintainerPermissions } ================================================ FILE: server/forge/gitlab/status.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package gitlab import ( gitlab "gitlab.com/gitlab-org/api/client-go/v2" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // getStatus is a helper that converts a Woodpecker status to a Gitlab status. func getStatus(status model.StatusValue) gitlab.BuildStateValue { switch status { case model.StatusPending, model.StatusBlocked: return gitlab.Pending case model.StatusRunning: return gitlab.Running case model.StatusSuccess: return gitlab.Success case model.StatusFailure, model.StatusError: return gitlab.Failed case model.StatusKilled: return gitlab.Canceled default: return gitlab.Failed } } ================================================ FILE: server/forge/mocks/mock_Forge.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" "net/http" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockForge creates a new instance of MockForge. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockForge(t interface { mock.TestingT Cleanup(func()) }) *MockForge { mock := &MockForge{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockForge is an autogenerated mock type for the Forge type type MockForge struct { mock.Mock } type MockForge_Expecter struct { mock *mock.Mock } func (_m *MockForge) EXPECT() *MockForge_Expecter { return &MockForge_Expecter{mock: &_m.Mock} } // Activate provides a mock function for the type MockForge func (_mock *MockForge) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error { ret := _mock.Called(ctx, u, r, link) if len(ret) == 0 { panic("no return value specified for Activate") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) error); ok { r0 = returnFunc(ctx, u, r, link) } else { r0 = ret.Error(0) } return r0 } // MockForge_Activate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Activate' type MockForge_Activate_Call struct { *mock.Call } // Activate is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - link string func (_e *MockForge_Expecter) Activate(ctx interface{}, u interface{}, r interface{}, link interface{}) *MockForge_Activate_Call { return &MockForge_Activate_Call{Call: _e.mock.On("Activate", ctx, u, r, link)} } func (_c *MockForge_Activate_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, link string)) *MockForge_Activate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 string if args[3] != nil { arg3 = args[3].(string) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockForge_Activate_Call) Return(err error) *MockForge_Activate_Call { _c.Call.Return(err) return _c } func (_c *MockForge_Activate_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, link string) error) *MockForge_Activate_Call { _c.Call.Return(run) return _c } // BranchHead provides a mock function for the type MockForge func (_mock *MockForge) BranchHead(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error) { ret := _mock.Called(ctx, u, r, branch) if len(ret) == 0 { panic("no return value specified for BranchHead") } var r0 *model.Commit var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) (*model.Commit, error)); ok { return returnFunc(ctx, u, r, branch) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) *model.Commit); ok { r0 = returnFunc(ctx, u, r, branch) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Commit) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, string) error); ok { r1 = returnFunc(ctx, u, r, branch) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_BranchHead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BranchHead' type MockForge_BranchHead_Call struct { *mock.Call } // BranchHead is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - branch string func (_e *MockForge_Expecter) BranchHead(ctx interface{}, u interface{}, r interface{}, branch interface{}) *MockForge_BranchHead_Call { return &MockForge_BranchHead_Call{Call: _e.mock.On("BranchHead", ctx, u, r, branch)} } func (_c *MockForge_BranchHead_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, branch string)) *MockForge_BranchHead_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 string if args[3] != nil { arg3 = args[3].(string) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockForge_BranchHead_Call) Return(commit *model.Commit, err error) *MockForge_BranchHead_Call { _c.Call.Return(commit, err) return _c } func (_c *MockForge_BranchHead_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, branch string) (*model.Commit, error)) *MockForge_BranchHead_Call { _c.Call.Return(run) return _c } // Branches provides a mock function for the type MockForge func (_mock *MockForge) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) { ret := _mock.Called(ctx, u, r, p) if len(ret) == 0 { panic("no return value specified for Branches") } var r0 []string var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) ([]string, error)); ok { return returnFunc(ctx, u, r, p) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) []string); ok { r0 = returnFunc(ctx, u, r, p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]string) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) error); ok { r1 = returnFunc(ctx, u, r, p) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Branches_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Branches' type MockForge_Branches_Call struct { *mock.Call } // Branches is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - p *model.ListOptions func (_e *MockForge_Expecter) Branches(ctx interface{}, u interface{}, r interface{}, p interface{}) *MockForge_Branches_Call { return &MockForge_Branches_Call{Call: _e.mock.On("Branches", ctx, u, r, p)} } func (_c *MockForge_Branches_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions)) *MockForge_Branches_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 *model.ListOptions if args[3] != nil { arg3 = args[3].(*model.ListOptions) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockForge_Branches_Call) Return(strings []string, err error) *MockForge_Branches_Call { _c.Call.Return(strings, err) return _c } func (_c *MockForge_Branches_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error)) *MockForge_Branches_Call { _c.Call.Return(run) return _c } // Deactivate provides a mock function for the type MockForge func (_mock *MockForge) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error { ret := _mock.Called(ctx, u, r, link) if len(ret) == 0 { panic("no return value specified for Deactivate") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, string) error); ok { r0 = returnFunc(ctx, u, r, link) } else { r0 = ret.Error(0) } return r0 } // MockForge_Deactivate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Deactivate' type MockForge_Deactivate_Call struct { *mock.Call } // Deactivate is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - link string func (_e *MockForge_Expecter) Deactivate(ctx interface{}, u interface{}, r interface{}, link interface{}) *MockForge_Deactivate_Call { return &MockForge_Deactivate_Call{Call: _e.mock.On("Deactivate", ctx, u, r, link)} } func (_c *MockForge_Deactivate_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, link string)) *MockForge_Deactivate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 string if args[3] != nil { arg3 = args[3].(string) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockForge_Deactivate_Call) Return(err error) *MockForge_Deactivate_Call { _c.Call.Return(err) return _c } func (_c *MockForge_Deactivate_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, link string) error) *MockForge_Deactivate_Call { _c.Call.Return(run) return _c } // Dir provides a mock function for the type MockForge func (_mock *MockForge) Dir(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error) { ret := _mock.Called(ctx, u, r, b, dirName) if len(ret) == 0 { panic("no return value specified for Dir") } var r0 []*types.FileMeta var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) ([]*types.FileMeta, error)); ok { return returnFunc(ctx, u, r, b, dirName) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) []*types.FileMeta); ok { r0 = returnFunc(ctx, u, r, b, dirName) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*types.FileMeta) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) error); ok { r1 = returnFunc(ctx, u, r, b, dirName) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Dir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dir' type MockForge_Dir_Call struct { *mock.Call } // Dir is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - b *model.Pipeline // - dirName string func (_e *MockForge_Expecter) Dir(ctx interface{}, u interface{}, r interface{}, b interface{}, dirName interface{}) *MockForge_Dir_Call { return &MockForge_Dir_Call{Call: _e.mock.On("Dir", ctx, u, r, b, dirName)} } func (_c *MockForge_Dir_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string)) *MockForge_Dir_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 *model.Pipeline if args[3] != nil { arg3 = args[3].(*model.Pipeline) } var arg4 string if args[4] != nil { arg4 = args[4].(string) } run( arg0, arg1, arg2, arg3, arg4, ) }) return _c } func (_c *MockForge_Dir_Call) Return(fileMetas []*types.FileMeta, err error) *MockForge_Dir_Call { _c.Call.Return(fileMetas, err) return _c } func (_c *MockForge_Dir_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, dirName string) ([]*types.FileMeta, error)) *MockForge_Dir_Call { _c.Call.Return(run) return _c } // File provides a mock function for the type MockForge func (_mock *MockForge) File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error) { ret := _mock.Called(ctx, u, r, b, fileName) if len(ret) == 0 { panic("no return value specified for File") } var r0 []byte var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) ([]byte, error)); ok { return returnFunc(ctx, u, r, b, fileName) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) []byte); ok { r0 = returnFunc(ctx, u, r, b, fileName) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, string) error); ok { r1 = returnFunc(ctx, u, r, b, fileName) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_File_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'File' type MockForge_File_Call struct { *mock.Call } // File is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - b *model.Pipeline // - fileName string func (_e *MockForge_Expecter) File(ctx interface{}, u interface{}, r interface{}, b interface{}, fileName interface{}) *MockForge_File_Call { return &MockForge_File_Call{Call: _e.mock.On("File", ctx, u, r, b, fileName)} } func (_c *MockForge_File_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string)) *MockForge_File_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 *model.Pipeline if args[3] != nil { arg3 = args[3].(*model.Pipeline) } var arg4 string if args[4] != nil { arg4 = args[4].(string) } run( arg0, arg1, arg2, arg3, arg4, ) }) return _c } func (_c *MockForge_File_Call) Return(bytes []byte, err error) *MockForge_File_Call { _c.Call.Return(bytes, err) return _c } func (_c *MockForge_File_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, fileName string) ([]byte, error)) *MockForge_File_Call { _c.Call.Return(run) return _c } // Hook provides a mock function for the type MockForge func (_mock *MockForge) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) { ret := _mock.Called(ctx, r) if len(ret) == 0 { panic("no return value specified for Hook") } var r0 *model.Repo var r1 *model.Pipeline var r2 error if returnFunc, ok := ret.Get(0).(func(context.Context, *http.Request) (*model.Repo, *model.Pipeline, error)); ok { return returnFunc(ctx, r) } if returnFunc, ok := ret.Get(0).(func(context.Context, *http.Request) *model.Repo); ok { r0 = returnFunc(ctx, r) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *http.Request) *model.Pipeline); ok { r1 = returnFunc(ctx, r) } else { if ret.Get(1) != nil { r1 = ret.Get(1).(*model.Pipeline) } } if returnFunc, ok := ret.Get(2).(func(context.Context, *http.Request) error); ok { r2 = returnFunc(ctx, r) } else { r2 = ret.Error(2) } return r0, r1, r2 } // MockForge_Hook_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Hook' type MockForge_Hook_Call struct { *mock.Call } // Hook is a helper method to define mock.On call // - ctx context.Context // - r *http.Request func (_e *MockForge_Expecter) Hook(ctx interface{}, r interface{}) *MockForge_Hook_Call { return &MockForge_Hook_Call{Call: _e.mock.On("Hook", ctx, r)} } func (_c *MockForge_Hook_Call) Run(run func(ctx context.Context, r *http.Request)) *MockForge_Hook_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *http.Request if args[1] != nil { arg1 = args[1].(*http.Request) } run( arg0, arg1, ) }) return _c } func (_c *MockForge_Hook_Call) Return(repo *model.Repo, pipeline *model.Pipeline, err error) *MockForge_Hook_Call { _c.Call.Return(repo, pipeline, err) return _c } func (_c *MockForge_Hook_Call) RunAndReturn(run func(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error)) *MockForge_Hook_Call { _c.Call.Return(run) return _c } // Login provides a mock function for the type MockForge func (_mock *MockForge) Login(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error) { ret := _mock.Called(ctx, r) if len(ret) == 0 { panic("no return value specified for Login") } var r0 *model.User var r1 string var r2 error if returnFunc, ok := ret.Get(0).(func(context.Context, *types.OAuthRequest) (*model.User, string, error)); ok { return returnFunc(ctx, r) } if returnFunc, ok := ret.Get(0).(func(context.Context, *types.OAuthRequest) *model.User); ok { r0 = returnFunc(ctx, r) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.User) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *types.OAuthRequest) string); ok { r1 = returnFunc(ctx, r) } else { r1 = ret.Get(1).(string) } if returnFunc, ok := ret.Get(2).(func(context.Context, *types.OAuthRequest) error); ok { r2 = returnFunc(ctx, r) } else { r2 = ret.Error(2) } return r0, r1, r2 } // MockForge_Login_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Login' type MockForge_Login_Call struct { *mock.Call } // Login is a helper method to define mock.On call // - ctx context.Context // - r *types.OAuthRequest func (_e *MockForge_Expecter) Login(ctx interface{}, r interface{}) *MockForge_Login_Call { return &MockForge_Login_Call{Call: _e.mock.On("Login", ctx, r)} } func (_c *MockForge_Login_Call) Run(run func(ctx context.Context, r *types.OAuthRequest)) *MockForge_Login_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *types.OAuthRequest if args[1] != nil { arg1 = args[1].(*types.OAuthRequest) } run( arg0, arg1, ) }) return _c } func (_c *MockForge_Login_Call) Return(user *model.User, s string, err error) *MockForge_Login_Call { _c.Call.Return(user, s, err) return _c } func (_c *MockForge_Login_Call) RunAndReturn(run func(ctx context.Context, r *types.OAuthRequest) (*model.User, string, error)) *MockForge_Login_Call { _c.Call.Return(run) return _c } // Name provides a mock function for the type MockForge func (_mock *MockForge) Name() string { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for Name") } var r0 string if returnFunc, ok := ret.Get(0).(func() string); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(string) } return r0 } // MockForge_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' type MockForge_Name_Call struct { *mock.Call } // Name is a helper method to define mock.On call func (_e *MockForge_Expecter) Name() *MockForge_Name_Call { return &MockForge_Name_Call{Call: _e.mock.On("Name")} } func (_c *MockForge_Name_Call) Run(run func()) *MockForge_Name_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockForge_Name_Call) Return(s string) *MockForge_Name_Call { _c.Call.Return(s) return _c } func (_c *MockForge_Name_Call) RunAndReturn(run func() string) *MockForge_Name_Call { _c.Call.Return(run) return _c } // Netrc provides a mock function for the type MockForge func (_mock *MockForge) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) { ret := _mock.Called(u, r) if len(ret) == 0 { panic("no return value specified for Netrc") } var r0 *model.Netrc var r1 error if returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) (*model.Netrc, error)); ok { return returnFunc(u, r) } if returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) *model.Netrc); ok { r0 = returnFunc(u, r) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Netrc) } } if returnFunc, ok := ret.Get(1).(func(*model.User, *model.Repo) error); ok { r1 = returnFunc(u, r) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Netrc_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Netrc' type MockForge_Netrc_Call struct { *mock.Call } // Netrc is a helper method to define mock.On call // - u *model.User // - r *model.Repo func (_e *MockForge_Expecter) Netrc(u interface{}, r interface{}) *MockForge_Netrc_Call { return &MockForge_Netrc_Call{Call: _e.mock.On("Netrc", u, r)} } func (_c *MockForge_Netrc_Call) Run(run func(u *model.User, r *model.Repo)) *MockForge_Netrc_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } var arg1 *model.Repo if args[1] != nil { arg1 = args[1].(*model.Repo) } run( arg0, arg1, ) }) return _c } func (_c *MockForge_Netrc_Call) Return(netrc *model.Netrc, err error) *MockForge_Netrc_Call { _c.Call.Return(netrc, err) return _c } func (_c *MockForge_Netrc_Call) RunAndReturn(run func(u *model.User, r *model.Repo) (*model.Netrc, error)) *MockForge_Netrc_Call { _c.Call.Return(run) return _c } // Org provides a mock function for the type MockForge func (_mock *MockForge) Org(ctx context.Context, u *model.User, org string) (*model.Org, error) { ret := _mock.Called(ctx, u, org) if len(ret) == 0 { panic("no return value specified for Org") } var r0 *model.Org var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.Org, error)); ok { return returnFunc(ctx, u, org) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.Org); ok { r0 = returnFunc(ctx, u, org) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Org) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok { r1 = returnFunc(ctx, u, org) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Org_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Org' type MockForge_Org_Call struct { *mock.Call } // Org is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - org string func (_e *MockForge_Expecter) Org(ctx interface{}, u interface{}, org interface{}) *MockForge_Org_Call { return &MockForge_Org_Call{Call: _e.mock.On("Org", ctx, u, org)} } func (_c *MockForge_Org_Call) Run(run func(ctx context.Context, u *model.User, org string)) *MockForge_Org_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockForge_Org_Call) Return(org1 *model.Org, err error) *MockForge_Org_Call { _c.Call.Return(org1, err) return _c } func (_c *MockForge_Org_Call) RunAndReturn(run func(ctx context.Context, u *model.User, org string) (*model.Org, error)) *MockForge_Org_Call { _c.Call.Return(run) return _c } // OrgMembership provides a mock function for the type MockForge func (_mock *MockForge) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) { ret := _mock.Called(ctx, u, org) if len(ret) == 0 { panic("no return value specified for OrgMembership") } var r0 *model.OrgPerm var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.OrgPerm, error)); ok { return returnFunc(ctx, u, org) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.OrgPerm); ok { r0 = returnFunc(ctx, u, org) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.OrgPerm) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok { r1 = returnFunc(ctx, u, org) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_OrgMembership_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgMembership' type MockForge_OrgMembership_Call struct { *mock.Call } // OrgMembership is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - org string func (_e *MockForge_Expecter) OrgMembership(ctx interface{}, u interface{}, org interface{}) *MockForge_OrgMembership_Call { return &MockForge_OrgMembership_Call{Call: _e.mock.On("OrgMembership", ctx, u, org)} } func (_c *MockForge_OrgMembership_Call) Run(run func(ctx context.Context, u *model.User, org string)) *MockForge_OrgMembership_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockForge_OrgMembership_Call) Return(orgPerm *model.OrgPerm, err error) *MockForge_OrgMembership_Call { _c.Call.Return(orgPerm, err) return _c } func (_c *MockForge_OrgMembership_Call) RunAndReturn(run func(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)) *MockForge_OrgMembership_Call { _c.Call.Return(run) return _c } // PullRequests provides a mock function for the type MockForge func (_mock *MockForge) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) { ret := _mock.Called(ctx, u, r, p) if len(ret) == 0 { panic("no return value specified for PullRequests") } var r0 []*model.PullRequest var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) ([]*model.PullRequest, error)); ok { return returnFunc(ctx, u, r, p) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) []*model.PullRequest); ok { r0 = returnFunc(ctx, u, r, p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.PullRequest) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.Repo, *model.ListOptions) error); ok { r1 = returnFunc(ctx, u, r, p) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_PullRequests_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PullRequests' type MockForge_PullRequests_Call struct { *mock.Call } // PullRequests is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - p *model.ListOptions func (_e *MockForge_Expecter) PullRequests(ctx interface{}, u interface{}, r interface{}, p interface{}) *MockForge_PullRequests_Call { return &MockForge_PullRequests_Call{Call: _e.mock.On("PullRequests", ctx, u, r, p)} } func (_c *MockForge_PullRequests_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions)) *MockForge_PullRequests_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 *model.ListOptions if args[3] != nil { arg3 = args[3].(*model.ListOptions) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockForge_PullRequests_Call) Return(pullRequests []*model.PullRequest, err error) *MockForge_PullRequests_Call { _c.Call.Return(pullRequests, err) return _c } func (_c *MockForge_PullRequests_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error)) *MockForge_PullRequests_Call { _c.Call.Return(run) return _c } // Repo provides a mock function for the type MockForge func (_mock *MockForge) Repo(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner string, name string) (*model.Repo, error) { ret := _mock.Called(ctx, u, remoteID, owner, name) if len(ret) == 0 { panic("no return value specified for Repo") } var r0 *model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, model.ForgeRemoteID, string, string) (*model.Repo, error)); ok { return returnFunc(ctx, u, remoteID, owner, name) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, model.ForgeRemoteID, string, string) *model.Repo); ok { r0 = returnFunc(ctx, u, remoteID, owner, name) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, model.ForgeRemoteID, string, string) error); ok { r1 = returnFunc(ctx, u, remoteID, owner, name) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Repo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Repo' type MockForge_Repo_Call struct { *mock.Call } // Repo is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - remoteID model.ForgeRemoteID // - owner string // - name string func (_e *MockForge_Expecter) Repo(ctx interface{}, u interface{}, remoteID interface{}, owner interface{}, name interface{}) *MockForge_Repo_Call { return &MockForge_Repo_Call{Call: _e.mock.On("Repo", ctx, u, remoteID, owner, name)} } func (_c *MockForge_Repo_Call) Run(run func(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner string, name string)) *MockForge_Repo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 model.ForgeRemoteID if args[2] != nil { arg2 = args[2].(model.ForgeRemoteID) } var arg3 string if args[3] != nil { arg3 = args[3].(string) } var arg4 string if args[4] != nil { arg4 = args[4].(string) } run( arg0, arg1, arg2, arg3, arg4, ) }) return _c } func (_c *MockForge_Repo_Call) Return(repo *model.Repo, err error) *MockForge_Repo_Call { _c.Call.Return(repo, err) return _c } func (_c *MockForge_Repo_Call) RunAndReturn(run func(ctx context.Context, u *model.User, remoteID model.ForgeRemoteID, owner string, name string) (*model.Repo, error)) *MockForge_Repo_Call { _c.Call.Return(run) return _c } // Repos provides a mock function for the type MockForge func (_mock *MockForge) Repos(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error) { ret := _mock.Called(ctx, u, p) if len(ret) == 0 { panic("no return value specified for Repos") } var r0 []*model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) ([]*model.Repo, error)); ok { return returnFunc(ctx, u, p) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) []*model.Repo); ok { r0 = returnFunc(ctx, u, p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.ListOptions) error); ok { r1 = returnFunc(ctx, u, p) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Repos_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Repos' type MockForge_Repos_Call struct { *mock.Call } // Repos is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - p *model.ListOptions func (_e *MockForge_Expecter) Repos(ctx interface{}, u interface{}, p interface{}) *MockForge_Repos_Call { return &MockForge_Repos_Call{Call: _e.mock.On("Repos", ctx, u, p)} } func (_c *MockForge_Repos_Call) Run(run func(ctx context.Context, u *model.User, p *model.ListOptions)) *MockForge_Repos_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.ListOptions if args[2] != nil { arg2 = args[2].(*model.ListOptions) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockForge_Repos_Call) Return(repos []*model.Repo, err error) *MockForge_Repos_Call { _c.Call.Return(repos, err) return _c } func (_c *MockForge_Repos_Call) RunAndReturn(run func(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Repo, error)) *MockForge_Repos_Call { _c.Call.Return(run) return _c } // Status provides a mock function for the type MockForge func (_mock *MockForge) Status(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error { ret := _mock.Called(ctx, u, r, b, p) if len(ret) == 0 { panic("no return value specified for Status") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.Repo, *model.Pipeline, *model.Workflow) error); ok { r0 = returnFunc(ctx, u, r, b, p) } else { r0 = ret.Error(0) } return r0 } // MockForge_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status' type MockForge_Status_Call struct { *mock.Call } // Status is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - r *model.Repo // - b *model.Pipeline // - p *model.Workflow func (_e *MockForge_Expecter) Status(ctx interface{}, u interface{}, r interface{}, b interface{}, p interface{}) *MockForge_Status_Call { return &MockForge_Status_Call{Call: _e.mock.On("Status", ctx, u, r, b, p)} } func (_c *MockForge_Status_Call) Run(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow)) *MockForge_Status_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.Repo if args[2] != nil { arg2 = args[2].(*model.Repo) } var arg3 *model.Pipeline if args[3] != nil { arg3 = args[3].(*model.Pipeline) } var arg4 *model.Workflow if args[4] != nil { arg4 = args[4].(*model.Workflow) } run( arg0, arg1, arg2, arg3, arg4, ) }) return _c } func (_c *MockForge_Status_Call) Return(err error) *MockForge_Status_Call { _c.Call.Return(err) return _c } func (_c *MockForge_Status_Call) RunAndReturn(run func(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, p *model.Workflow) error) *MockForge_Status_Call { _c.Call.Return(run) return _c } // Teams provides a mock function for the type MockForge func (_mock *MockForge) Teams(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error) { ret := _mock.Called(ctx, u, p) if len(ret) == 0 { panic("no return value specified for Teams") } var r0 []*model.Team var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) ([]*model.Team, error)); ok { return returnFunc(ctx, u, p) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User, *model.ListOptions) []*model.Team); ok { r0 = returnFunc(ctx, u, p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Team) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User, *model.ListOptions) error); ok { r1 = returnFunc(ctx, u, p) } else { r1 = ret.Error(1) } return r0, r1 } // MockForge_Teams_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Teams' type MockForge_Teams_Call struct { *mock.Call } // Teams is a helper method to define mock.On call // - ctx context.Context // - u *model.User // - p *model.ListOptions func (_e *MockForge_Expecter) Teams(ctx interface{}, u interface{}, p interface{}) *MockForge_Teams_Call { return &MockForge_Teams_Call{Call: _e.mock.On("Teams", ctx, u, p)} } func (_c *MockForge_Teams_Call) Run(run func(ctx context.Context, u *model.User, p *model.ListOptions)) *MockForge_Teams_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } var arg2 *model.ListOptions if args[2] != nil { arg2 = args[2].(*model.ListOptions) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockForge_Teams_Call) Return(teams []*model.Team, err error) *MockForge_Teams_Call { _c.Call.Return(teams, err) return _c } func (_c *MockForge_Teams_Call) RunAndReturn(run func(ctx context.Context, u *model.User, p *model.ListOptions) ([]*model.Team, error)) *MockForge_Teams_Call { _c.Call.Return(run) return _c } // URL provides a mock function for the type MockForge func (_mock *MockForge) URL() string { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for URL") } var r0 string if returnFunc, ok := ret.Get(0).(func() string); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(string) } return r0 } // MockForge_URL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'URL' type MockForge_URL_Call struct { *mock.Call } // URL is a helper method to define mock.On call func (_e *MockForge_Expecter) URL() *MockForge_URL_Call { return &MockForge_URL_Call{Call: _e.mock.On("URL")} } func (_c *MockForge_URL_Call) Run(run func()) *MockForge_URL_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockForge_URL_Call) Return(s string) *MockForge_URL_Call { _c.Call.Return(s) return _c } func (_c *MockForge_URL_Call) RunAndReturn(run func() string) *MockForge_URL_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/forge/mocks/mock_Refresher.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockRefresher creates a new instance of MockRefresher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockRefresher(t interface { mock.TestingT Cleanup(func()) }) *MockRefresher { mock := &MockRefresher{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockRefresher is an autogenerated mock type for the Refresher type type MockRefresher struct { mock.Mock } type MockRefresher_Expecter struct { mock *mock.Mock } func (_m *MockRefresher) EXPECT() *MockRefresher_Expecter { return &MockRefresher_Expecter{mock: &_m.Mock} } // Refresh provides a mock function for the type MockRefresher func (_mock *MockRefresher) Refresh(ctx context.Context, u *model.User) (bool, error) { ret := _mock.Called(ctx, u) if len(ret) == 0 { panic("no return value specified for Refresh") } var r0 bool var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User) (bool, error)); ok { return returnFunc(ctx, u) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.User) bool); ok { r0 = returnFunc(ctx, u) } else { r0 = ret.Get(0).(bool) } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.User) error); ok { r1 = returnFunc(ctx, u) } else { r1 = ret.Error(1) } return r0, r1 } // MockRefresher_Refresh_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Refresh' type MockRefresher_Refresh_Call struct { *mock.Call } // Refresh is a helper method to define mock.On call // - ctx context.Context // - u *model.User func (_e *MockRefresher_Expecter) Refresh(ctx interface{}, u interface{}) *MockRefresher_Refresh_Call { return &MockRefresher_Refresh_Call{Call: _e.mock.On("Refresh", ctx, u)} } func (_c *MockRefresher_Refresh_Call) Run(run func(ctx context.Context, u *model.User)) *MockRefresher_Refresh_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.User if args[1] != nil { arg1 = args[1].(*model.User) } run( arg0, arg1, ) }) return _c } func (_c *MockRefresher_Refresh_Call) Return(b bool, err error) *MockRefresher_Refresh_Call { _c.Call.Return(b, err) return _c } func (_c *MockRefresher_Refresh_Call) RunAndReturn(run func(ctx context.Context, u *model.User) (bool, error)) *MockRefresher_Refresh_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/forge/refresh.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forge import ( "context" "fmt" "time" "github.com/rs/zerolog/log" "golang.org/x/sync/singleflight" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // Refresher is an optional interface for OAuth token refresh support. // // Tokens are checked before each operation. If expiring within 30 minutes, // Refresh() is called automatically. // // Implementations: GitLab, Bitbucket (GitHub/Gitea tokens don't expire). type Refresher interface { // Refresh attempts to refresh the user's OAuth access token. // Should update u.AccessToken, u.RefreshToken, and u.Expiry. // Returns true if any fields were updated. // Caller must persist updated user to database. Refresh(ctx context.Context, u *model.User) (bool, error) } // refreshGroup deduplicates concurrent token refresh calls per user. // When multiple goroutines try to refresh the same user's token simultaneously // (e.g., from concurrent API requests), only one refresh executes and the // others wait for its result. This prevents race conditions with single-use // refresh tokens (e.g., Forgejo with InvalidateRefreshTokens=true). var refreshGroup singleflight.Group // refreshResult carries token data through singleflight so waiting goroutines // can update their own *model.User copies. type refreshResult struct { AccessToken string RefreshToken string Expiry int64 } func Refresh(ctx context.Context, forge Forge, _store store.Store, user *model.User) { // Remaining ttl of 30 minutes (1800 seconds) until a token is refreshed. const tokenMinTTL = 1800 if refresher, ok := forge.(Refresher); ok { // Check to see if the user token is expired or // will expire within the next 30 minutes (1800 seconds). // If not, there is nothing we really need to do here. if time.Now().UTC().Unix() < (user.Expiry - tokenMinTTL) { return } key := fmt.Sprintf("refresh-%d", user.ID) result, err, _ := refreshGroup.Do(key, func() (any, error) { userUpdated, err := refresher.Refresh(ctx, user) if err != nil { return nil, err } if userUpdated { if err := _store.UpdateUser(user); err != nil { log.Error().Err(err).Msg("fail to save user to store after refresh oauth token") } } return &refreshResult{ AccessToken: user.AccessToken, RefreshToken: user.RefreshToken, Expiry: user.Expiry, }, nil }) if err != nil { log.Error().Err(err).Msgf("refresh oauth token of user '%s' failed", user.Login) return } // Copy fresh tokens into the caller's user object. This is necessary // because waiting goroutines have their own *model.User copies that // weren't passed to refresher.Refresh(). if r, ok := result.(*refreshResult); ok { user.AccessToken = r.AccessToken user.RefreshToken = r.RefreshToken user.Expiry = r.Expiry } } } ================================================ FILE: server/forge/refresh_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package forge_test import ( "context" "fmt" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/model" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) // refresherForge combines MockForge (satisfies forge.Forge) and MockRefresher // (satisfies forge.Refresher) so the Refresh function's type assertion succeeds. type refresherForge struct { *forge_mocks.MockForge *forge_mocks.MockRefresher } func expiredUser(id int64) *model.User { return &model.User{ ID: id, Login: fmt.Sprintf("user%d", id), AccessToken: "old-access-token", RefreshToken: "old-refresh-token", Expiry: time.Now().UTC().Unix() - 100, // expired } } func freshUser(id int64) *model.User { return &model.User{ ID: id, Login: fmt.Sprintf("user%d", id), AccessToken: "valid-access-token", RefreshToken: "valid-refresh-token", Expiry: time.Now().UTC().Unix() + 7200, // 2 hours from now } } func TestRefresh_NonExpiredToken(t *testing.T) { mockForge := forge_mocks.NewMockForge(t) mockRefresher := forge_mocks.NewMockRefresher(t) mockStore := store_mocks.NewMockStore(t) f := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher} user := freshUser(1) forge.Refresh(context.Background(), f, mockStore, user) // Refresher.Refresh should NOT be called since token is still valid mockRefresher.AssertNotCalled(t, "Refresh", mock.Anything, mock.Anything) } func TestRefresh_ExpiredToken(t *testing.T) { mockForge := forge_mocks.NewMockForge(t) mockRefresher := forge_mocks.NewMockRefresher(t) mockStore := store_mocks.NewMockStore(t) f := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher} user := expiredUser(1) mockRefresher.On("Refresh", mock.Anything, user).Return(true, nil).Run(func(args mock.Arguments) { u, ok := args.Get(1).(*model.User) if !ok { return } u.AccessToken = "new-access-token" u.RefreshToken = "new-refresh-token" u.Expiry = time.Now().UTC().Unix() + 3600 }) mockStore.On("UpdateUser", user).Return(nil) forge.Refresh(context.Background(), f, mockStore, user) assert.Equal(t, "new-access-token", user.AccessToken) assert.Equal(t, "new-refresh-token", user.RefreshToken) mockRefresher.AssertCalled(t, "Refresh", mock.Anything, user) mockStore.AssertCalled(t, "UpdateUser", user) } func TestRefresh_ExpiredTokenNoUpdate(t *testing.T) { mockForge := forge_mocks.NewMockForge(t) mockRefresher := forge_mocks.NewMockRefresher(t) mockStore := store_mocks.NewMockStore(t) f := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher} user := expiredUser(2) // Refresh returns false (no update needed), e.g. token was already refreshed mockRefresher.On("Refresh", mock.Anything, user).Return(false, nil) forge.Refresh(context.Background(), f, mockStore, user) mockRefresher.AssertCalled(t, "Refresh", mock.Anything, user) // UpdateUser should NOT be called when Refresh returns false mockStore.AssertNotCalled(t, "UpdateUser", mock.Anything) } func TestRefresh_ConcurrentRefreshSerialized(t *testing.T) { mockForge := forge_mocks.NewMockForge(t) mockRefresher := forge_mocks.NewMockRefresher(t) mockStore := store_mocks.NewMockStore(t) f := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher} var refreshCount atomic.Int32 mockRefresher.On("Refresh", mock.Anything, mock.Anything).Return(true, nil).Run(func(args mock.Arguments) { refreshCount.Add(1) // Simulate network latency so concurrent callers overlap time.Sleep(50 * time.Millisecond) u, ok := args.Get(1).(*model.User) if !ok { return } u.AccessToken = "new-access-token" u.RefreshToken = "new-refresh-token" u.Expiry = time.Now().UTC().Unix() + 3600 }) mockStore.On("UpdateUser", mock.Anything).Return(nil) const numGoroutines = 10 var wg sync.WaitGroup users := make([]*model.User, numGoroutines) for i := 0; i < numGoroutines; i++ { users[i] = expiredUser(42) // same user ID } wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func(u *model.User) { defer wg.Done() forge.Refresh(context.Background(), f, mockStore, u) }(users[i]) } wg.Wait() // Only one actual refresh call should have been made assert.Equal(t, int32(1), refreshCount.Load(), "expected exactly 1 refresh call, got %d", refreshCount.Load()) // All goroutines should have the fresh tokens for i := 0; i < len(users); i++ { assert.Equal(t, "new-access-token", users[i].AccessToken, "user[%d] missing new access token", i) assert.Equal(t, "new-refresh-token", users[i].RefreshToken, "user[%d] missing new refresh token", i) } } func TestRefresh_ConcurrentRefreshError(t *testing.T) { mockForge := forge_mocks.NewMockForge(t) mockRefresher := forge_mocks.NewMockRefresher(t) mockStore := store_mocks.NewMockStore(t) f := &refresherForge{MockForge: mockForge, MockRefresher: mockRefresher} mockRefresher.On("Refresh", mock.Anything, mock.Anything).Return(false, fmt.Errorf("token was already used")).Run(func(_ mock.Arguments) { time.Sleep(50 * time.Millisecond) }) const numGoroutines = 5 var wg sync.WaitGroup users := make([]*model.User, numGoroutines) for i := 0; i < numGoroutines; i++ { users[i] = expiredUser(99) // same user ID } wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func(u *model.User) { defer wg.Done() forge.Refresh(context.Background(), f, mockStore, u) }(users[i]) } wg.Wait() // Tokens should remain unchanged (error path) for i := 0; i < len(users); i++ { assert.Equal(t, "old-access-token", users[i].AccessToken, "user[%d] token should be unchanged after error", i) } // Store.UpdateUser should NOT be called on error mockStore.AssertNotCalled(t, "UpdateUser", mock.Anything) } func TestRefresh_NonRefresherForge(t *testing.T) { // MockForge does NOT implement Refresher, so the type assertion should fail // and Refresh should be a no-op mockForge := forge_mocks.NewMockForge(t) mockStore := store_mocks.NewMockStore(t) user := expiredUser(1) forge.Refresh(context.Background(), mockForge, mockStore, user) // Token should be unchanged assert.Equal(t, "old-access-token", user.AccessToken) mockStore.AssertNotCalled(t, "UpdateUser", mock.Anything) } ================================================ FILE: server/forge/setup/setup.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package setup import ( "fmt" "net/url" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/addon" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucket" "go.woodpecker-ci.org/woodpecker/v3/server/forge/bitbucketdatacenter" "go.woodpecker-ci.org/woodpecker/v3/server/forge/forgejo" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitea" "go.woodpecker-ci.org/woodpecker/v3/server/forge/github" "go.woodpecker-ci.org/woodpecker/v3/server/forge/gitlab" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func Forge(forge *model.Forge) (forge.Forge, error) { switch forge.Type { case model.ForgeTypeAddon: return setupAddon(forge) case model.ForgeTypeGithub: return setupGitHub(forge) case model.ForgeTypeGitlab: return setupGitLab(forge) case model.ForgeTypeBitbucket: return setupBitbucket(forge) case model.ForgeTypeGitea: return setupGitea(forge) case model.ForgeTypeForgejo: return setupForgejo(forge) case model.ForgeTypeBitbucketDatacenter: return setupBitbucketDatacenter(forge) default: return nil, fmt.Errorf("forge not configured") } } func setupBitbucket(forge *model.Forge) (forge.Forge, error) { opts := &bitbucket.Opts{ OAuthClientID: forge.OAuthClientID, OAuthClientSecret: forge.OAuthClientSecret, } log.Debug(). Bool("oauth-client-id-set", opts.OAuthClientID != ""). Bool("oauth-client-secret-set", opts.OAuthClientSecret != ""). Str("type", string(forge.Type)). Msg("setting up forge") return bitbucket.New(forge.ID, opts) } func setupGitea(forge *model.Forge) (forge.Forge, error) { serverURL, err := url.Parse(forge.URL) if err != nil { return nil, err } opts := gitea.Opts{ URL: strings.TrimRight(serverURL.String(), "/"), OAuthClientID: forge.OAuthClientID, OAuthClientSecret: forge.OAuthClientSecret, SkipVerify: forge.SkipVerify, OAuthHost: forge.OAuthHost, } if len(opts.URL) == 0 { return nil, fmt.Errorf("WOODPECKER_GITEA_URL must be set") } log.Debug(). Str("url", opts.URL). Str("oauth-host", opts.OAuthHost). Bool("skip-verify", opts.SkipVerify). Bool("oauth-client-id-set", opts.OAuthClientID != ""). Bool("oauth-secret-id-set", opts.OAuthClientSecret != ""). Str("type", string(forge.Type)). Msg("setting up forge") return gitea.New(forge.ID, opts) } func setupForgejo(forge *model.Forge) (forge.Forge, error) { server, err := url.Parse(forge.URL) if err != nil { return nil, err } opts := forgejo.Opts{ URL: strings.TrimRight(server.String(), "/"), OAuthClientID: forge.OAuthClientID, OAuthClientSecret: forge.OAuthClientSecret, SkipVerify: forge.SkipVerify, OAuth2URL: forge.OAuthHost, } if len(opts.URL) == 0 { return nil, fmt.Errorf("WOODPECKER_FORGEJO_URL must be set") } log.Debug(). Str("url", opts.URL). Str("oauth2-url", opts.OAuth2URL). Bool("skip-verify", opts.SkipVerify). Bool("oauth-client-id-set", opts.OAuthClientID != ""). Bool("oauth-client-secret-set", opts.OAuthClientSecret != ""). Str("type", string(forge.Type)). Msg("setting up forge") return forgejo.New(forge.ID, opts) } func setupGitLab(forge *model.Forge) (forge.Forge, error) { opts := gitlab.Opts{ URL: forge.URL, OAuthClientID: forge.OAuthClientID, OAuthClientSecret: forge.OAuthClientSecret, SkipVerify: forge.SkipVerify, OAuthHost: forge.OAuthHost, } log.Debug(). Str("url", opts.URL). Str("oauth-host", opts.OAuthHost). Bool("skip-verify", opts.SkipVerify). Bool("oauth-client-id-set", opts.OAuthClientID != ""). Bool("oauth-client-secret-set", opts.OAuthClientSecret != ""). Str("type", string(forge.Type)). Msg("setting up forge") return gitlab.New(forge.ID, opts) } func setupGitHub(forge *model.Forge) (forge.Forge, error) { // get additional config and be false by default mergeRef, _ := forge.AdditionalOptions["merge-ref"].(bool) publicOnly, _ := forge.AdditionalOptions["public-only"].(bool) opts := github.Opts{ URL: forge.URL, OAuthClientID: forge.OAuthClientID, OAuthClientSecret: forge.OAuthClientSecret, SkipVerify: forge.SkipVerify, MergeRef: mergeRef, OnlyPublic: publicOnly, OAuthHost: forge.OAuthHost, } log.Debug(). Str("url", opts.URL). Str("oauth-host", opts.OAuthHost). Bool("merge-ref", opts.MergeRef). Bool("only-public", opts.OnlyPublic). Bool("skip-verify", opts.SkipVerify). Bool("oauth-client-id-set", opts.OAuthClientID != ""). Bool("oauth-client-secret-set", opts.OAuthClientSecret != ""). Str("type", string(forge.Type)). Msg("setting up forge") return github.New(forge.ID, opts) } func setupBitbucketDatacenter(forge *model.Forge) (forge.Forge, error) { gitUsername, ok := forge.AdditionalOptions["git-username"].(string) if !ok { return nil, fmt.Errorf("missing git-username") } gitPassword, ok := forge.AdditionalOptions["git-password"].(string) if !ok { return nil, fmt.Errorf("missing git-password") } enableProjectAdminScope, ok := forge.AdditionalOptions["oauth-enable-project-admin-scope"].(bool) if !ok { return nil, fmt.Errorf("incorrect type for oauth-enable-project-admin-scope value") } opts := bitbucketdatacenter.Opts{ URL: forge.URL, OAuthClientID: forge.OAuthClientID, OAuthClientSecret: forge.OAuthClientSecret, Username: gitUsername, Password: gitPassword, OAuthHost: forge.OAuthHost, OAuthEnableProjectAdminScope: enableProjectAdminScope, } log.Debug(). Str("url", opts.URL). Str("oauth-host", opts.OAuthHost). Bool("oauth-client-id-set", opts.OAuthClientID != ""). Bool("oauth-client-secret-set", opts.OAuthClientSecret != ""). Str("type", string(forge.Type)). Bool("oauth-enable-project-admin-scope", opts.OAuthEnableProjectAdminScope). Msg("setting up forge") return bitbucketdatacenter.New(forge.ID, opts) } func setupAddon(forge *model.Forge) (forge.Forge, error) { executable, ok := forge.AdditionalOptions["executable"].(string) if !ok { return nil, fmt.Errorf("missing addon executable") } log.Debug().Str("executable", executable).Msg("setting up forge") return addon.Load(executable) } ================================================ FILE: server/forge/types/errors.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "errors" "fmt" "strings" ) var ( ErrNotImplemented = errors.New("not implemented") ErrRepoNotFound = errors.New("repo not found") ) type ErrIgnoreEvent struct { Event string Reason string } func (err *ErrIgnoreEvent) Error() string { if err.Reason != "" { return fmt.Sprintf("explicit ignored event '%s', reason: %s", err.Event, err.Reason) } return fmt.Sprintf("explicit ignored event '%s'", err.Event) } func (*ErrIgnoreEvent) Is(target error) bool { _, ok := target.(*ErrIgnoreEvent) return ok } type ErrConfigNotFound struct { Configs []string } func (m *ErrConfigNotFound) Error() string { return fmt.Sprintf("configs not found: %s", strings.Join(m.Configs, ", ")) } func (*ErrConfigNotFound) Is(target error) bool { _, ok := target.(*ErrConfigNotFound) return ok } ================================================ FILE: server/forge/types/meta.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import "sort" // FileMeta represents a file in version control. type FileMeta struct { Name string Data []byte } type fileMetaList []*FileMeta func (a fileMetaList) Len() int { return len(a) } func (a fileMetaList) Less(i, j int) bool { return a[i].Name < a[j].Name } func (a fileMetaList) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func SortByName(fm []*FileMeta) []*FileMeta { l := fileMetaList(fm) sort.Sort(l) return l } ================================================ FILE: server/forge/types/meta_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "testing" "github.com/stretchr/testify/assert" ) func TestSortByName(t *testing.T) { fm := []*FileMeta{ { Name: "a", }, { Name: "c", }, { Name: "b", }, } assert.Equal(t, []*FileMeta{ { Name: "a", }, { Name: "b", }, { Name: "c", }, }, SortByName(fm)) } ================================================ FILE: server/forge/types/oauth.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types type OAuthRequest struct { Code string State string } ================================================ FILE: server/logging/LICENSE ================================================ BSD 3-Clause License Copyright (c) 2017, Brad Rydzewski All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: server/logging/log.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logging import ( "context" "sync" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // TODO: (bradrydzewski) writing to subscribers is currently a blocking // operation and does not protect against slow clients from locking // the stream. This should be resolved. //nolint:godot // TODO: (bradrydzewski) implement a mux.Info to fetch information and // statistics for the multiplexer. Streams, subscribers, etc // mux.Info() //nolint:godot // TODO: (bradrydzewski) refactor code to place publisher and subscriber // operations in separate files with more encapsulated logic. // sub.push() // sub.join() // sub.start()... event loop type subscriber struct { receiver LogChan } type stream struct { sync.Mutex stepID int64 list []*model.LogEntry subs map[*subscriber]struct{} done chan struct{} } type logger struct { sync.Mutex streams map[int64]*stream } // New returns a new logger. func New() Log { return &logger{ streams: map[int64]*stream{}, } } func (l *logger) Open(_ context.Context, stepID int64) error { l.Lock() l.open(stepID) l.Unlock() return nil } func (l *logger) open(stepID int64) { _, ok := l.streams[stepID] if !ok { l.streams[stepID] = &stream{ stepID: stepID, subs: make(map[*subscriber]struct{}), done: make(chan struct{}), } } } func (l *logger) Write(ctx context.Context, stepID int64, entries []*model.LogEntry) error { l.Lock() s, ok := l.streams[stepID] if !ok { // Auto-open the stream while still holding the logger lock so that a // concurrent Write for the same step cannot race on l.streams. l.open(stepID) s = l.streams[stepID] } l.Unlock() s.Lock() s.list = append(s.list, entries...) for sub := range s.subs { select { case sub.receiver <- entries: default: log.Info().Msgf("subscriber channel is full -- dropping logs for step %d", stepID) } } s.Unlock() return nil } func (l *logger) Tail(c context.Context, stepID int64, receiver LogChan) error { l.Lock() s, ok := l.streams[stepID] l.Unlock() if !ok { return ErrNotFound } sub := &subscriber{ receiver: receiver, } s.Lock() if len(s.list) != 0 { sub.receiver <- s.list } s.subs[sub] = struct{}{} s.Unlock() select { case <-c.Done(): case <-s.done: } s.Lock() delete(s.subs, sub) s.Unlock() return nil } func (l *logger) Close(_ context.Context, stepID int64) error { l.Lock() s, ok := l.streams[stepID] l.Unlock() if !ok { return ErrNotFound } s.Lock() close(s.done) s.Unlock() l.Lock() delete(l.streams, stepID) l.Unlock() return nil } ================================================ FILE: server/logging/log_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logging import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestLogging(t *testing.T) { var ( wg sync.WaitGroup testStepID = int64(123) testEntry = &model.LogEntry{ Data: []byte("test"), } ) ctx, cancel := context.WithCancelCause( t.Context(), ) receiver := make(LogChan, 10) defer close(receiver) go func() { for range receiver { wg.Done() } }() logger := New() assert.NoError(t, logger.Open(ctx, testStepID)) go func() { assert.NoError(t, logger.Tail(ctx, testStepID, receiver)) }() go func() { assert.NoError(t, logger.Tail(ctx, testStepID, receiver)) }() <-time.After(500 * time.Millisecond) wg.Add(4) go func() { assert.NoError(t, logger.Write(ctx, testStepID, []*model.LogEntry{testEntry})) assert.NoError(t, logger.Write(ctx, testStepID, []*model.LogEntry{testEntry})) }() wg.Wait() wg.Add(1) go func() { assert.NoError(t, logger.Tail(ctx, testStepID, receiver)) }() <-time.After(500 * time.Millisecond) wg.Wait() cancel(nil) } ================================================ FILE: server/logging/logging.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logging import ( "context" "errors" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // ErrNotFound is returned when the log does not exist. var ErrNotFound = errors.New("stream: not found") // LogChan defines a channel type for receiving ordered batches of log entries. type LogChan chan []*model.LogEntry // Log defines a log multiplexer. type Log interface { // Open opens the log. Open(c context.Context, stepID int64) error // Write writes the entry to the log. Write(c context.Context, stepID int64, entries []*model.LogEntry) error // Tail tails the log. Tail(c context.Context, stepID int64, handler LogChan) error // Close closes the log. Close(c context.Context, stepID int64) error } ================================================ FILE: server/model/agent.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "encoding/base32" "fmt" "github.com/tink-crypto/tink-go/v2/subtle/random" "go.woodpecker-ci.org/woodpecker/v3/pipeline" ) type Agent struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` Created int64 `json:"created" xorm:"created"` Updated int64 `json:"updated" xorm:"updated"` Name string `json:"name" xorm:"name"` OwnerID int64 `json:"owner_id" xorm:"'owner_id'"` Token string `json:"token" xorm:"token"` LastContact int64 `json:"last_contact" xorm:"last_contact"` LastWork int64 `json:"last_work" xorm:"last_work"` // last time the agent did something, this value is used to determine if the agent is still doing work used by the autoscaler Platform string `json:"platform" xorm:"VARCHAR(100) 'platform'"` Backend string `json:"backend" xorm:"VARCHAR(100) 'backend'"` Capacity int32 `json:"capacity" xorm:"capacity"` Version string `json:"version" xorm:"'version'"` NoSchedule bool `json:"no_schedule" xorm:"no_schedule"` CustomLabels map[string]string `json:"custom_labels" xorm:"JSON 'custom_labels'"` // OrgID is counted as unset if set to -1, this is done to ensure a new(Agent) still enforce the OrgID check by default OrgID int64 `json:"org_id" xorm:"INDEX 'org_id'"` } // @name Agent const ( IDNotSet = -1 ) // TableName return database table name for xorm. func (Agent) TableName() string { return "agents" } func (a *Agent) IsSystemAgent() bool { return a.OwnerID == IDNotSet && a.OrgID == IDNotSet } func GenerateNewAgentToken() string { return base32.StdEncoding.EncodeToString(random.GetRandomBytes(32)) } func (a *Agent) GetServerLabels() (map[string]string, error) { filters := make(map[string]string) // enforce filters for user and organization agents if a.OrgID != IDNotSet { filters[pipeline.LabelFilterOrg] = fmt.Sprintf("%d", a.OrgID) } else { filters[pipeline.LabelFilterOrg] = "*" } return filters, nil } func (a *Agent) CanAccessRepo(repo *Repo) bool { // global agent if a.OrgID == IDNotSet { return true } // agent has access to the organization if a.OrgID == repo.OrgID { return true } return false } ================================================ FILE: server/model/agent_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline" ) func TestGenerateNewAgentToken(t *testing.T) { token1 := GenerateNewAgentToken() token2 := GenerateNewAgentToken() assert.NotEmpty(t, token1) assert.NotEmpty(t, token2) assert.NotEqual(t, token1, token2) assert.Len(t, token1, 56) } func TestAgent_GetServerLabels(t *testing.T) { t.Run("EmptyAgent", func(t *testing.T) { agent := &Agent{} filters, err := agent.GetServerLabels() assert.NoError(t, err) assert.Equal(t, map[string]string{ pipeline.LabelFilterOrg: "0", }, filters) }) t.Run("GlobalAgent", func(t *testing.T) { agent := &Agent{ OrgID: IDNotSet, } filters, err := agent.GetServerLabels() assert.NoError(t, err) assert.Equal(t, map[string]string{ pipeline.LabelFilterOrg: "*", }, filters) }) t.Run("OrgAgent", func(t *testing.T) { agent := &Agent{ OrgID: 123, } filters, err := agent.GetServerLabels() assert.NoError(t, err) assert.Equal(t, map[string]string{ pipeline.LabelFilterOrg: "123", }, filters) }) } func TestAgent_CanAccessRepo(t *testing.T) { repo := &Repo{ID: 123, OrgID: 12} otherRepo := &Repo{ID: 456, OrgID: 45} t.Run("EmptyAgent", func(t *testing.T) { agent := &Agent{} assert.False(t, agent.CanAccessRepo(repo)) }) t.Run("GlobalAgent", func(t *testing.T) { agent := &Agent{ OrgID: IDNotSet, } assert.True(t, agent.CanAccessRepo(repo)) }) t.Run("OrgAgent", func(t *testing.T) { agent := &Agent{ OrgID: 12, } assert.True(t, agent.CanAccessRepo(repo)) assert.False(t, agent.CanAccessRepo(otherRepo)) }) } ================================================ FILE: server/model/commit.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model type Commit struct { SHA string ForgeURL string } ================================================ FILE: server/model/config.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Config represents a pipeline configuration. type Config struct { ID int64 `json:"-" xorm:"pk autoincr 'id'"` RepoID int64 `json:"-" xorm:"UNIQUE(s) 'repo_id'"` Hash string `json:"hash" xorm:"UNIQUE(s) 'hash'"` Name string `json:"name" xorm:"UNIQUE(s) 'name'"` Data []byte `json:"data" xorm:"LONGBLOB 'data'"` } // @name Config func (Config) TableName() string { return "configs" } // PipelineConfig is the n:n relation between Pipeline and Config. type PipelineConfig struct { ConfigID int64 `json:"-" xorm:"UNIQUE(s) NOT NULL 'config_id'"` PipelineID int64 `json:"-" xorm:"UNIQUE(s) NOT NULL 'pipeline_id'"` } func (PipelineConfig) TableName() string { return "pipeline_configs" } ================================================ FILE: server/model/const.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "errors" "fmt" ) type WebhookEvent string // @name WebhookEvent const ( EventPush WebhookEvent = "push" EventPull WebhookEvent = "pull_request" EventPullClosed WebhookEvent = "pull_request_closed" EventPullMetadata WebhookEvent = "pull_request_metadata" EventTag WebhookEvent = "tag" EventRelease WebhookEvent = "release" EventDeploy WebhookEvent = "deployment" EventCron WebhookEvent = "cron" EventManual WebhookEvent = "manual" ) type WebhookEventList []WebhookEvent func (wel WebhookEventList) Len() int { return len(wel) } func (wel WebhookEventList) Swap(i, j int) { wel[i], wel[j] = wel[j], wel[i] } func (wel WebhookEventList) Less(i, j int) bool { return wel[i] < wel[j] } var ErrInvalidWebhookEvent = errors.New("invalid webhook event") func (s WebhookEvent) Validate() error { switch s { case EventPush, EventPull, EventPullClosed, EventPullMetadata, EventTag, EventRelease, EventDeploy, EventCron, EventManual: return nil default: return fmt.Errorf("%w: %s", ErrInvalidWebhookEvent, s) } } // StatusValue represent pipeline states woodpecker know. type StatusValue string // @name StatusValue const ( StatusSkipped StatusValue = "skipped" // skipped as per condition of current workflow failed/success state StatusPending StatusValue = "pending" // pending to be executed StatusRunning StatusValue = "running" // currently running StatusSuccess StatusValue = "success" // successfully finished StatusFailure StatusValue = "failure" // failed to finish (exit code != 0) StatusKilled StatusValue = "killed" // killed by user StatusCanceled StatusValue = "canceled" // canceled but hasn't been started StatusError StatusValue = "error" // error with the config / while parsing / some other system problem StatusBlocked StatusValue = "blocked" // waiting for approval StatusDeclined StatusValue = "declined" // blocked and declined StatusCreated StatusValue = "created" // created / internal use only ) var ErrInvalidStatusValue = errors.New("invalid status value") func (s StatusValue) Validate() error { switch s { case StatusSkipped, StatusPending, StatusRunning, StatusSuccess, StatusFailure, StatusKilled, StatusCanceled, StatusError, StatusBlocked, StatusDeclined, StatusCreated: return nil default: return fmt.Errorf("%w: %s", ErrInvalidStatusValue, s) } } // RepoVisibility represent to what state a repo in woodpecker is visible to others. type RepoVisibility string // @name RepoVisibility const ( VisibilityPublic RepoVisibility = "public" VisibilityPrivate RepoVisibility = "private" VisibilityInternal RepoVisibility = "internal" ) ================================================ FILE: server/model/cron.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "fmt" "github.com/gdgvda/cron" ) type Cron struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` Name string `json:"name" xorm:"name UNIQUE(s) INDEX"` RepoID int64 `json:"repo_id" xorm:"repo_id UNIQUE(s) INDEX"` CreatorID int64 `json:"creator_id" xorm:"creator_id INDEX"` // TODO: drop with next major version NextExec int64 `json:"next_exec" xorm:"next_exec"` Schedule string `json:"schedule" xorm:"schedule NOT NULL"` // @weekly, 3min, ... Created int64 `json:"created" xorm:"created NOT NULL DEFAULT 0"` Branch string `json:"branch" xorm:"branch"` Enabled bool `json:"enabled" xorm:"enabled NOT NULL DEFAULT TRUE"` Variables map[string]string `json:"variables" xorm:"json 'variables'"` } // @name Cron // TableName returns the database table name for xorm. func (Cron) TableName() string { return "crons" } // Validate ensures cron has a valid name and schedule. func (c *Cron) Validate() error { if c.Name == "" { return fmt.Errorf("name is required") } if c.Schedule == "" { return fmt.Errorf("schedule is required") } parser, err := cron.NewDefaultParser(cron.StandardOptions) if err != nil { return fmt.Errorf("can't create parser: %w", err) } _, err = parser.Parse(c.Schedule) if err != nil { return fmt.Errorf("can't parse schedule: %w", err) } return nil } type CronPatch struct { Name *string `json:"name"` Schedule *string `json:"schedule"` Branch *string `json:"branch"` Enabled *bool `json:"enabled"` Variables map[string]string `json:"variables"` } // @name CronPatch ================================================ FILE: server/model/environ.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "errors" ) var ( errEnvironNameInvalid = errors.New("invalid Environment Variable Name") errEnvironValueInvalid = errors.New("invalid Environment Variable Value") ) // Environ represents an environment variable. type Environ struct { Name string `json:"name"` Value string `json:"value,omitempty"` } // Validate validates the required fields and formats. func (e *Environ) Validate() error { switch { case len(e.Name) == 0: return errEnvironNameInvalid case len(e.Value) == 0: return errEnvironValueInvalid default: return nil } } // Copy makes a copy of the environment variable without the value. func (e *Environ) Copy() *Environ { return &Environ{ Name: e.Name, } } ================================================ FILE: server/model/event.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // EventType defines the possible types of pipeline events. type EventType string // Event represents a pipeline event. type Event struct { Repo Repo `json:"repo"` Pipeline Pipeline `json:"pipeline"` } ================================================ FILE: server/model/feed.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Feed represents an item in the user's feed or timeline. type Feed struct { RepoID int64 `json:"repo_id" xorm:"repo_id"` ID int64 `json:"id,omitempty" xorm:"pipeline_id"` Number int64 `json:"number,omitempty" xorm:"pipeline_number"` Event string `json:"event,omitempty" xorm:"pipeline_event"` Status string `json:"status,omitempty" xorm:"pipeline_status"` Created int64 `json:"created,omitempty" xorm:"pipeline_created"` Started int64 `json:"started,omitempty" xorm:"pipeline_started"` Finished int64 `json:"finished,omitempty" xorm:"pipeline_finished"` Commit string `json:"commit,omitempty" xorm:"pipeline_commit"` Branch string `json:"branch,omitempty" xorm:"pipeline_branch"` Ref string `json:"ref,omitempty" xorm:"pipeline_ref"` Refspec string `json:"refspec,omitempty" xorm:"pipeline_refspec"` Title string `json:"title,omitempty" xorm:"pipeline_title"` Message string `json:"message,omitempty" xorm:"pipeline_message"` Author string `json:"author,omitempty" xorm:"pipeline_author"` Avatar string `json:"author_avatar,omitempty" xorm:"pipeline_avatar"` Email string `json:"author_email,omitempty" xorm:"pipeline_email"` } // @name Feed ================================================ FILE: server/model/forge.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model type ForgeType string const ( ForgeTypeGithub ForgeType = "github" ForgeTypeGitlab ForgeType = "gitlab" ForgeTypeGitea ForgeType = "gitea" ForgeTypeForgejo ForgeType = "forgejo" ForgeTypeBitbucket ForgeType = "bitbucket" ForgeTypeBitbucketDatacenter ForgeType = "bitbucket-dc" ForgeTypeAddon ForgeType = "addon" ) type Forge struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` Type ForgeType `json:"type" xorm:"VARCHAR(250)"` URL string `json:"url" xorm:"VARCHAR(500) 'url'"` OAuthClientID string `json:"client,omitempty" xorm:"VARCHAR(250) 'oauth_client_id'"` OAuthClientSecret string `json:"-" xorm:"VARCHAR(250) 'oauth_client_secret'"` // do not expose client secret SkipVerify bool `json:"skip_verify,omitempty" xorm:"bool"` OAuthHost string `json:"oauth_host,omitempty" xorm:"VARCHAR(250) 'oauth_host'"` // public url for oauth if different from url AdditionalOptions map[string]any `json:"additional_options,omitempty" xorm:"json"` } // @name Forge // TableName returns the database table name for xorm. func (Forge) TableName() string { return "forges" } // PublicCopy returns a copy of the forge without sensitive information and technical details. func (f *Forge) PublicCopy() *Forge { forge := &Forge{ ID: f.ID, Type: f.Type, URL: f.URL, } return forge } // ForgeWithOAuthClientSecret allows to update the client secret. type ForgeWithOAuthClientSecret struct { Forge OAuthClientSecret string `json:"oauth_client_secret"` } // @name ForgeWithOAuthClientSecret ================================================ FILE: server/model/log.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // LogEntryType identifies the type of line in the logs. type LogEntryType int // @name LogEntryType const ( LogEntryStdout LogEntryType = iota LogEntryStderr LogEntryExitCode LogEntryMetadata LogEntryProgress ) type LogEntry struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` StepID int64 `json:"step_id" xorm:"INDEX 'step_id'"` Time int64 `json:"time" xorm:"'time'"` Line int `json:"line" xorm:"'line'"` Data []byte `json:"data" xorm:"LONGBLOB"` Created int64 `json:"-" xorm:"created"` Type LogEntryType `json:"type" xorm:"'type'"` } // @name LogEntry // TODO: store info what specific command the line belongs to (must be optional and impl. by backend) func (LogEntry) TableName() string { return "log_entries" } ================================================ FILE: server/model/netrc.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model type Netrc struct { Machine string `json:"machine"` Login string `json:"login"` Password string `json:"password"` Type ForgeType `json:"type"` } ================================================ FILE: server/model/org.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Org represents an organization. type Org struct { ID int64 `json:"id,omitempty" xorm:"pk autoincr 'id'"` ForgeID int64 `json:"forge_id,omitempty" xorm:"forge_id UNIQUE(s)"` Name string `json:"name" xorm:"'name' UNIQUE(s)"` IsUser bool `json:"is_user" xorm:"is_user"` // if name lookup has to check for membership or not Private bool `json:"-" xorm:"private"` } // @name Org // TableName return database table name for xorm. func (Org) TableName() string { return "orgs" } ================================================ FILE: server/model/pagination.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "fmt" "strings" ) type ListOptions struct { All bool Page int PerPage int } func ApplyPagination[T any](d *ListOptions, slice []T) []T { if d.All { return slice } if d.PerPage*(d.Page-1) > len(slice) { return []T{} } if d.PerPage*(d.Page) > len(slice) { return slice[d.PerPage*(d.Page-1):] } return slice[d.PerPage*(d.Page-1) : d.PerPage*(d.Page)] } func (d *ListOptions) Encode() string { var query []string if d.Page != 0 { query = append(query, fmt.Sprintf("page=%d", d.Page)) } if d.PerPage != 0 { query = append(query, fmt.Sprintf("per_page=%d", d.PerPage)) } if d.All { query = append(query, "all=true") } return strings.Join(query, "&") } ================================================ FILE: server/model/pagination_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestApplyPagination(t *testing.T) { example := []int{ 0, 1, 2, } assert.Equal(t, ApplyPagination(&ListOptions{All: true}, example), example) assert.Equal(t, ApplyPagination(&ListOptions{Page: 1, PerPage: 1}, example), []int{0}) assert.Equal(t, ApplyPagination(&ListOptions{Page: 2, PerPage: 2}, example), []int{2}) assert.Equal(t, ApplyPagination(&ListOptions{Page: 3, PerPage: 1}, example), []int{2}) assert.Equal(t, ApplyPagination(&ListOptions{Page: 4, PerPage: 1}, example), []int{}) assert.Equal(t, ApplyPagination(&ListOptions{Page: 5, PerPage: 1}, example), []int{}) } ================================================ FILE: server/model/perm.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Perm defines a repository permission for an individual user. type Perm struct { UserID int64 `json:"-" xorm:"UNIQUE(s) INDEX NOT NULL 'user_id'"` RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX NOT NULL 'repo_id'"` Pull bool `json:"pull" xorm:"pull"` Push bool `json:"push" xorm:"push"` Admin bool `json:"admin" xorm:"admin"` Synced int64 `json:"synced" xorm:"synced"` Created int64 `json:"created" xorm:"created"` Updated int64 `json:"updated" xorm:"updated"` } // @name Perm // TableName return database table name for xorm. func (Perm) TableName() string { return "perms" } // OrgPerm defines an organization permission for an individual user. type OrgPerm struct { Member bool `json:"member"` Admin bool `json:"admin"` } // @name OrgPerm ================================================ FILE: server/model/pipeline.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" ) type Pipeline struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` RepoID int64 `json:"-" xorm:"UNIQUE(s) INDEX 'repo_id'"` Number int64 `json:"number" xorm:"UNIQUE(s) 'number'"` Author string `json:"author" xorm:"INDEX 'author'"` Parent int64 `json:"parent" xorm:"parent"` Event WebhookEvent `json:"event" xorm:"event"` EventReason []string `json:"event_reason" xorm:"json 'event_reason'"` Status StatusValue `json:"status" xorm:"INDEX 'status'"` Errors []*errors.PipelineError `json:"errors" xorm:"json 'errors'"` Created int64 `json:"created" xorm:"'created' NOT NULL DEFAULT 0 created"` Updated int64 `json:"updated" xorm:"'updated' NOT NULL DEFAULT 0 updated"` Started int64 `json:"started" xorm:"started"` Finished int64 `json:"finished" xorm:"finished"` DeployTo string `json:"deploy_to" xorm:"deploy"` DeployTask string `json:"deploy_task" xorm:"deploy_task"` Commit string `json:"commit" xorm:"commit"` Branch string `json:"branch" xorm:"branch"` Ref string `json:"ref" xorm:"ref"` Refspec string `json:"refspec" xorm:"refspec"` Title string `json:"title" xorm:"title"` Message string `json:"message" xorm:"TEXT 'message'"` Timestamp int64 `json:"timestamp" xorm:"'timestamp'"` Sender string `json:"sender" xorm:"sender"` // uses reported user for webhooks and name of cron for cron pipelines Avatar string `json:"author_avatar" xorm:"varchar(500) avatar"` Email string `json:"author_email" xorm:"varchar(500) email"` ForgeURL string `json:"forge_url" xorm:"forge_url"` Reviewer string `json:"reviewed_by" xorm:"reviewer"` Reviewed int64 `json:"reviewed" xorm:"reviewed"` CancelInfo *CancelInfo `json:"cancel_info,omitempty" xorm:"json 'cancel_info'"` Workflows []*Workflow `json:"workflows,omitempty" xorm:"-"` ChangedFiles []string `json:"changed_files,omitempty" xorm:"LONGTEXT 'changed_files'"` AdditionalVariables map[string]string `json:"variables,omitempty" xorm:"json 'additional_variables'"` PullRequestLabels []string `json:"pr_labels,omitempty" xorm:"json 'pr_labels'"` PullRequestMilestone string `json:"pr_milestone,omitempty" xorm:"pr_milestone"` Cron string `json:"cron,omitempty" xorm:"cron"` // name of the cron job IsPrerelease bool `json:"is_prerelease,omitempty" xorm:"is_prerelease"` FromFork bool `json:"from_fork,omitempty" xorm:"from_fork"` Version string `json:"version" xorm:"'version'"` } // APIPipeline TODO remove deprecated properties in next major. type APIPipeline struct { *Pipeline } // @name Pipeline // TableName return database table name for xorm. func (Pipeline) TableName() string { return "pipelines" } func (p *Pipeline) ToAPIModel() *APIPipeline { ap := &APIPipeline{ Pipeline: p, } switch p.Event { //nolint:gocritic case EventCron: ap.Message = p.Cron ap.Sender = p.Cron } return ap } type PipelineFilter struct { Before int64 After int64 Branch string Events []WebhookEvent RefContains string Status StatusValue } // IsMultiPipeline checks if step list contain more than one parent step. func (p Pipeline) IsMultiPipeline() bool { return len(p.Workflows) > 1 } // IsPullRequest checks if it's a PR event. func (p Pipeline) IsPullRequest() bool { return metadata.Event(p.Event).IsPull() } type PipelineOptions struct { Branch string `json:"branch"` Variables map[string]string `json:"variables"` } // @name PipelineOptions type CancelInfo struct { CanceledByUser string `json:"canceled_by_user,omitempty"` SupersededBy int64 `json:"superseded_by,omitempty"` CanceledByStep string `json:"canceled_by_step,omitempty"` } // @name CancelInfo ================================================ FILE: server/model/pull_request.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model type PullRequest struct { Index ForgeRemoteID `json:"index"` Title string `json:"title"` } // @name PullRequest ================================================ FILE: server/model/queue.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // QueueTask represents a task in the queue with additional API-specific fields. type QueueTask struct { Task PipelineNumber int64 `json:"pipeline_number"` AgentName string `json:"agent_name"` } // QueueInfo represents the response structure for queue information API. type QueueInfo struct { Pending []QueueTask `json:"pending"` WaitingOnDeps []QueueTask `json:"waiting_on_deps"` Running []QueueTask `json:"running"` Stats struct { WorkerCount int `json:"worker_count"` PendingCount int `json:"pending_count"` WaitingOnDepsCount int `json:"waiting_on_deps_count"` RunningCount int `json:"running_count"` } `json:"stats"` Paused bool `json:"paused"` } // @name QueueInfo ================================================ FILE: server/model/redirection.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model type Redirection struct { ID int64 `xorm:"pk autoincr 'id'"` RepoID int64 `xorm:"'repo_id'"` FullName string `xorm:"UNIQUE INDEX 'repo_full_name'"` } func (r Redirection) TableName() string { return "redirections" } ================================================ FILE: server/model/registry.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "errors" "net/url" ) var ( errRegistryAddressInvalid = errors.New("invalid registry address") errRegistryUsernameInvalid = errors.New("invalid registry username") errRegistryPasswordInvalid = errors.New("invalid registry password") ) // Registry represents a docker registry with credentials. type Registry struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` OrgID int64 `json:"org_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'org_id'"` RepoID int64 `json:"repo_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'repo_id'"` Address string `json:"address" xorm:"NOT NULL UNIQUE(s) INDEX 'address'"` Username string `json:"username" xorm:"varchar(2000) 'username'"` Password string `json:"password" xorm:"TEXT 'password'"` ReadOnly bool `json:"readonly" xorm:"-"` } // @name Registry func (r Registry) TableName() string { return "registries" } // Global registry. func (r Registry) IsGlobal() bool { return r.RepoID == 0 && r.OrgID == 0 } // Organization registry. func (r Registry) IsOrganization() bool { return r.RepoID == 0 && r.OrgID != 0 } // Repository registry. func (r Registry) IsRepository() bool { return r.RepoID != 0 && r.OrgID == 0 } // Validate validates the registry information. func (r *Registry) Validate() error { switch { case len(r.Address) == 0: return errRegistryAddressInvalid case len(r.Username) == 0: return errRegistryUsernameInvalid case len(r.Password) == 0: return errRegistryPasswordInvalid } _, err := url.Parse(r.Address) return err } // Copy makes a copy of the registry without the password. func (r *Registry) Copy() *Registry { return &Registry{ ID: r.ID, OrgID: r.OrgID, RepoID: r.RepoID, Address: r.Address, Username: r.Username, ReadOnly: r.ReadOnly, } } ================================================ FILE: server/model/repo.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "fmt" "strings" ) type ApprovalMode string const ( RequireApprovalNone ApprovalMode = "none" // require approval for no events RequireApprovalForks ApprovalMode = "forks" // require approval for PRs from forks (default) RequireApprovalPullRequests ApprovalMode = "pull_requests" // require approval for all PRs RequireApprovalAllEvents ApprovalMode = "all_events" // require approval for all external events ) func (mode ApprovalMode) Valid() bool { switch mode { case RequireApprovalNone, RequireApprovalForks, RequireApprovalPullRequests, RequireApprovalAllEvents: return true default: return false } } // Repo represents a repository. type Repo struct { ID int64 `json:"id,omitempty" xorm:"pk autoincr 'id'"` UserID int64 `json:"-" xorm:"INDEX 'user_id'"` ForgeID int64 `json:"forge_id,omitempty" xorm:"UNIQUE(forge) UNIQUE(name) UNIQUE(full_name) forge_id"` // ForgeRemoteID is the unique identifier for the repository on the forge. ForgeRemoteID ForgeRemoteID `json:"forge_remote_id" xorm:"UNIQUE(forge) forge_remote_id"` OrgID int64 `json:"org_id" xorm:"INDEX 'org_id'"` Owner string `json:"owner" xorm:"UNIQUE(name) 'owner'"` Name string `json:"name" xorm:"UNIQUE(name) 'name'"` FullName string `json:"full_name" xorm:"UNIQUE(full_name) 'full_name'"` Avatar string `json:"avatar_url,omitempty" xorm:"varchar(500) 'avatar'"` ForgeURL string `json:"forge_url,omitempty" xorm:"varchar(1000) 'forge_url'"` Clone string `json:"clone_url,omitempty" xorm:"varchar(1000) 'clone'"` CloneSSH string `json:"clone_url_ssh" xorm:"varchar(1000) 'clone_ssh'"` Branch string `json:"default_branch,omitempty" xorm:"varchar(500) 'branch'"` PREnabled bool `json:"pr_enabled" xorm:"DEFAULT TRUE 'pr_enabled'"` Timeout int64 `json:"timeout,omitempty" xorm:"timeout"` Visibility RepoVisibility `json:"visibility" xorm:"varchar(10) 'visibility'"` IsSCMPrivate bool `json:"private" xorm:"private"` Trusted TrustedConfiguration `json:"trusted" xorm:"json 'trusted'"` RequireApproval ApprovalMode `json:"require_approval" xorm:"varchar(50) require_approval"` ApprovalAllowedUsers []string `json:"approval_allowed_users" xorm:"json approval_allowed_users"` IsActive bool `json:"active" xorm:"active"` AllowPull bool `json:"allow_pr" xorm:"allow_pr"` AllowDeploy bool `json:"allow_deploy" xorm:"allow_deploy"` Config string `json:"config_file" xorm:"varchar(500) 'config_path'"` Hash string `json:"-" xorm:"varchar(500) 'hash'"` CancelPreviousPipelineEvents []WebhookEvent `json:"cancel_previous_pipeline_events" xorm:"json 'cancel_previous_pipeline_events'"` NetrcTrustedPlugins []string `json:"netrc_trusted" xorm:"json 'netrc_trusted'"` ConfigExtensionEndpoint string `json:"config_extension_endpoint" xorm:"varchar(500) 'config_extension_endpoint'"` ConfigExtensionExclusive bool `json:"config_extension_exclusive" xorm:"DEFAULT FALSE 'config_extension_exclusive'"` ConfigExtensionNetrc bool `json:"config_extension_netrc" xorm:"DEFAULT FALSE 'config_extension_netrc'"` RegistryExtensionEndpoint string `json:"registry_extension_endpoint" xorm:"varchar(500) 'registry_extension_endpoint'"` RegistryExtensionNetrc bool `json:"registry_extension_netrc" xorm:"DEFAULT FALSE 'registry_extension_netrc'"` SecretExtensionEndpoint string `json:"secret_extension_endpoint" xorm:"varchar(500) 'secret_extension_endpoint'"` SecretExtensionNetrc bool `json:"secret_extension_netrc" xorm:"DEFAULT FALSE 'secret_extension_netrc'"` // Rest API Only // HasForgeNameConflict is true if forge returned a repo with same name but different forge remote id HasForgeNameConflict bool `json:"has_forge_name_conflict,omitempty" xorm:"-"` // HasNoForgeRepo is true if repo only exist in the woodpecker store and not at the forge anymore HasNoForgeRepo bool ` json:"has_no_forge_repo,omitempty" xorm:"-"` // internal usage Perm *Perm `json:"-" xorm:"-"` } // @name Repo // TableName return database table name for xorm. func (Repo) TableName() string { return "repos" } type RepoFilter struct { Name string } func (r *Repo) ResetVisibility() { r.Visibility = VisibilityPublic if r.IsSCMPrivate { r.Visibility = VisibilityPrivate } } // ParseRepo parses the repository owner and name from a string. func ParseRepo(str string) (user, repo string, err error) { before, after, _ := strings.Cut(str, "/") if before == "" || after == "" { err = fmt.Errorf("invalid or missing repository (e.g. octocat/hello-world)") return user, repo, err } user = before repo = after return user, repo, err } // Update updates the repository with values from the given Repo. func (r *Repo) Update(from *Repo) { if from.ForgeRemoteID.IsValid() { r.ForgeRemoteID = from.ForgeRemoteID } r.Owner = from.Owner r.Name = from.Name r.FullName = from.FullName r.Avatar = from.Avatar r.ForgeURL = from.ForgeURL r.PREnabled = from.PREnabled if len(from.Clone) > 0 { r.Clone = from.Clone } if len(from.CloneSSH) > 0 { r.CloneSSH = from.CloneSSH } r.Branch = from.Branch // Only propagate visibility when the source supplies it. Some webhook // payloads (notably GitLab push/tag/merge events) do not include project // visibility, leaving from.Visibility empty and from.IsSCMPrivate at the // zero value. Updating the stored fields from those payloads would // overwrite the authoritative value previously synced from the forge API // during activation or repair, breaking netrc-protected clones. if from.Visibility != "" { r.Visibility = from.Visibility r.IsSCMPrivate = from.IsSCMPrivate } } // RepoPatch represents a repository patch object. type RepoPatch struct { Config *string `json:"config_file,omitempty"` RequireApproval *string `json:"require_approval,omitempty"` ApprovalAllowedUsers *[]string `json:"approval_allowed_users,omitempty"` Timeout *int64 `json:"timeout,omitempty"` Visibility *string `json:"visibility,omitempty"` AllowPull *bool `json:"allow_pr,omitempty"` AllowDeploy *bool `json:"allow_deploy,omitempty"` CancelPreviousPipelineEvents *[]WebhookEvent `json:"cancel_previous_pipeline_events"` NetrcTrusted *[]string `json:"netrc_trusted"` Trusted *TrustedConfigurationPatch `json:"trusted"` ConfigExtensionEndpoint *string `json:"config_extension_endpoint,omitempty"` ConfigExtensionExclusive *bool `json:"config_extension_exclusive"` ConfigExtensionNetrc *bool `json:"config_extension_netrc"` RegistryExtensionEndpoint *string `json:"registry_extension_endpoint,omitempty"` RegistryExtensionNetrc *bool `json:"registry_extension_netrc"` SecretExtensionEndpoint *string `json:"secret_extension_endpoint,omitempty"` SecretExtensionNetrc *bool `json:"secret_extension_netrc,omitempty"` } // @name RepoPatch type ForgeRemoteID string func (r ForgeRemoteID) IsValid() bool { return r != "" && r != "0" } type TrustedConfiguration struct { Network bool `json:"network"` Volumes bool `json:"volumes"` Security bool `json:"security"` } type TrustedConfigurationPatch struct { Network *bool `json:"network"` Volumes *bool `json:"volumes"` Security *bool `json:"security"` } // RepoLastPipeline represents a repository with last pipeline execution information. type RepoLastPipeline struct { *Repo LastPipeline *APIPipeline `json:"last_pipeline,omitempty"` } // @name RepoLastPipeline ================================================ FILE: server/model/repo_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestRepoUpdate_Visibility(t *testing.T) { tests := []struct { name string stored Repo from Repo wantVisibility RepoVisibility wantPrivate bool }{ { name: "empty source visibility preserves stored value", stored: Repo{Visibility: VisibilityPrivate, IsSCMPrivate: true}, from: Repo{Visibility: "", IsSCMPrivate: false}, wantVisibility: VisibilityPrivate, wantPrivate: true, }, { name: "empty source visibility preserves stored public value", stored: Repo{Visibility: VisibilityPublic, IsSCMPrivate: false}, from: Repo{Visibility: "", IsSCMPrivate: false}, wantVisibility: VisibilityPublic, wantPrivate: false, }, { name: "source can change public to private", stored: Repo{Visibility: VisibilityPublic, IsSCMPrivate: false}, from: Repo{Visibility: VisibilityPrivate, IsSCMPrivate: true}, wantVisibility: VisibilityPrivate, wantPrivate: true, }, { name: "source can change private to public", stored: Repo{Visibility: VisibilityPrivate, IsSCMPrivate: true}, from: Repo{Visibility: VisibilityPublic, IsSCMPrivate: false}, wantVisibility: VisibilityPublic, wantPrivate: false, }, { name: "internal visibility is preserved (not collapsed to private)", stored: Repo{Visibility: VisibilityPublic, IsSCMPrivate: false}, from: Repo{Visibility: VisibilityInternal, IsSCMPrivate: true}, wantVisibility: VisibilityInternal, wantPrivate: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := tt.stored r.Update(&tt.from) assert.Equal(t, tt.wantVisibility, r.Visibility) assert.Equal(t, tt.wantPrivate, r.IsSCMPrivate) }) } } ================================================ FILE: server/model/secret.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "errors" "fmt" "regexp" "sort" ) var ( ErrSecretNameInvalid = errors.New("invalid secret name") ErrSecretImageInvalid = errors.New("invalid secret image") ErrSecretValueInvalid = errors.New("invalid secret value") ErrSecretEventInvalid = errors.New("invalid secret event") ) // SecretStore persists secret information to storage. type SecretStore interface { SecretFind(*Repo, string) (*Secret, error) SecretList(*Repo, bool, *ListOptions) ([]*Secret, error) SecretCreate(*Secret) error SecretUpdate(*Secret) error SecretDelete(*Secret) error OrgSecretFind(int64, string) (*Secret, error) OrgSecretList(int64, *ListOptions) ([]*Secret, error) GlobalSecretFind(string) (*Secret, error) GlobalSecretList(*ListOptions) ([]*Secret, error) SecretListAll() ([]*Secret, error) } // Secret represents a secret variable, such as a password or token. type Secret struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` OrgID int64 `json:"org_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'org_id'"` RepoID int64 `json:"repo_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'repo_id'"` Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'name'"` Value string `json:"value,omitempty" xorm:"TEXT 'value'"` Images []string `json:"images" xorm:"json 'images'"` Events []WebhookEvent `json:"events" xorm:"json 'events'"` Note string `json:"note" xorm:"note"` } // @name Secret // TableName return database table name for xorm. func (Secret) TableName() string { return "secrets" } // BeforeInsert will sort events before inserted into database. func (s *Secret) BeforeInsert() { s.Events = sortEvents(s.Events) } // Global secret. func (s Secret) IsGlobal() bool { return s.RepoID == 0 && s.OrgID == 0 } // Organization secret. func (s Secret) IsOrganization() bool { return s.RepoID == 0 && s.OrgID != 0 } // Repository secret. func (s Secret) IsRepository() bool { return s.RepoID != 0 && s.OrgID == 0 } var validDockerImageString = regexp.MustCompile( `^(` + `[\w\d\-_\.]+` + // hostname `(:\d+)?` + // optional port `/)?` + // optional hostname + port `([\w\d\-_\.][\w\d\-_\.\/]*/)?` + // optional url prefix `([\w\d\-_\.]+)` + // image name `(:[\w\d\-_]+)?` + // optional image tag `$`, ) // Validate validates the required fields and formats. func (s *Secret) Validate() error { for _, event := range s.Events { if err := event.Validate(); err != nil { return errors.Join(err, ErrSecretEventInvalid) } } if len(s.Events) == 0 { return fmt.Errorf("%w: no event specified", ErrSecretEventInvalid) } for _, image := range s.Images { if len(image) == 0 { return fmt.Errorf("%w: empty image in images", ErrSecretImageInvalid) } if !validDockerImageString.MatchString(image) { return fmt.Errorf("%w: image '%s' do not match regexp '%s'", ErrSecretImageInvalid, image, validDockerImageString.String()) } } switch { case len(s.Name) == 0: return fmt.Errorf("%w: empty name", ErrSecretNameInvalid) case len(s.Value) == 0: return fmt.Errorf("%w: empty value", ErrSecretValueInvalid) default: return nil } } // Copy makes a copy of the secret without the value. func (s *Secret) Copy() *Secret { return &Secret{ ID: s.ID, OrgID: s.OrgID, RepoID: s.RepoID, Name: s.Name, Images: s.Images, Events: sortEvents(s.Events), Note: s.Note, } } func sortEvents(wel WebhookEventList) WebhookEventList { sort.Sort(wel) return wel } type SecretPatch struct { Name *string `json:"name" ` Value *string `json:"value,omitempty" ` Images []string `json:"images" ` Events []WebhookEvent `json:"events" ` Note *string `json:"note" ` } // @name SecretPatch ================================================ FILE: server/model/secret_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestSecretValidate(t *testing.T) { tests := []struct { s Secret err bool }{ { s: Secret{ Name: "secretname", Value: "secretvalue", Events: []WebhookEvent{EventPush}, Images: []string{"docker.io/library/mysql:latest", "alpine:latest", "localregistry.test:8443/mysql:latest", "localregistry.test:8443/library/mysql:latest", "docker.io/library/mysql", "alpine", "localregistry.test:8443/mysql", "localregistry.test:8443/library/mysql", "code.thinkaboutit.tech/pandora/woodpecker-config-server.goapp"}, }, err: false, }, { s: Secret{ Value: "secretvalue", Events: []WebhookEvent{EventPush}, Images: []string{"docker.io/library/mysql:latest", "alpine:latest", "localregistry.test:8443/mysql:latest", "localregistry.test:8443/library/mysql:latest", "docker.io/library/mysql", "alpine", "localregistry.test:8443/mysql", "localregistry.test:8443/library/mysql"}, }, err: true, }, { s: Secret{ Name: "secretname", Events: []WebhookEvent{EventPush}, Images: []string{"docker.io/library/mysql:latest", "alpine:latest", "localregistry.test:8443/mysql:latest", "localregistry.test:8443/library/mysql:latest", "docker.io/library/mysql", "alpine", "localregistry.test:8443/mysql", "localregistry.test:8443/library/mysql"}, }, err: true, }, { s: Secret{ Name: "secretname", Value: "secretvalue", Images: []string{"docker.io/library/mysql:latest", "alpine:latest", "localregistry.test:8443/mysql:latest", "localregistry.test:8443/library/mysql:latest", "docker.io/library/mysql", "alpine", "localregistry.test:8443/mysql", "localregistry.test:8443/library/mysql"}, }, err: true, }, { s: Secret{ Name: "secretname", Value: "secretvalue", Events: []WebhookEvent{EventPush}, Images: []string{"wrong image:no"}, }, err: true, }, { s: Secret{ Name: "secretname", Value: "secretvalue", Events: []WebhookEvent{EventPush}, Images: []string{"/library/mysql:latest", ":8443/mysql:latest", ":8443/library/mysql:latest", "/library/mysql", ":8443/mysql", ":8443/library/mysql"}, }, err: true, }, { s: Secret{ Name: "secretname", Value: "secretvalue", Events: []WebhookEvent{EventPush}, Images: []string{"localregistry.test:/mysql:latest", "localregistry.test:/mysql"}, }, err: true, }, { s: Secret{ Name: "secretname", Value: "secretvalue", Events: []WebhookEvent{EventPush}, Images: []string{"docker.io/library/mysql:", "alpine:", "localregistry.test:8443/mysql:", "localregistry.test:8443/library/mysql:"}, }, err: true, }, } for i, tt := range tests { err := tt.s.Validate() if tt.err { assert.Errorf(t, err, "expected secret validation error on index %d", i) } else { assert.NoErrorf(t, err, "unexpected secret validation error on index %d", i) } } } ================================================ FILE: server/model/server_config.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // ServerConfig represents a key-value pair for storing server configurations. type ServerConfig struct { Key string `json:"key" xorm:"pk 'key'"` Value string `json:"value" xorm:"value"` } // TableName return database table name for xorm. func (ServerConfig) TableName() string { return "server_configs" } ================================================ FILE: server/model/step.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Different ways to handle failure states. const ( FailureIgnore = "ignore" FailureFail = "fail" FailureCancel = "cancel" ) // Step represents a process in the pipeline. type Step struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` UUID string `json:"uuid" xorm:"INDEX 'uuid'"` PipelineID int64 `json:"pipeline_id" xorm:"UNIQUE(s) INDEX 'pipeline_id'"` PID int `json:"pid" xorm:"UNIQUE(s) 'pid'"` PPID int `json:"ppid" xorm:"ppid"` Name string `json:"name" xorm:"name"` State StatusValue `json:"state" xorm:"state"` Error string `json:"error,omitempty" xorm:"TEXT 'error'"` Failure string `json:"-" xorm:"failure"` ExitCode int `json:"exit_code" xorm:"exit_code"` Started int64 `json:"started,omitempty" xorm:"started"` Finished int64 `json:"finished,omitempty" xorm:"finished"` Type StepType `json:"type,omitempty" xorm:"type"` } // @name Step // TableName return database table name for xorm. func (Step) TableName() string { return "steps" } // Running returns true if the process state is pending or running. func (p *Step) Running() bool { return p.State == StatusPending || p.State == StatusRunning } // Failing returns true if the process state is failed, killed or error. func (p *Step) Failing() bool { return p.State == StatusError || p.State == StatusKilled || p.State == StatusFailure } // StepType identifies the type of step. type StepType string // @name StepType const ( StepTypeClone StepType = "clone" StepTypeService StepType = "service" StepTypePlugin StepType = "plugin" StepTypeCommands StepType = "commands" StepTypeCache StepType = "cache" ) ================================================ FILE: server/model/step_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestStepStatus(t *testing.T) { step := &Step{ State: StatusPending, } assert.Equal(t, step.Running(), true) step.State = StatusRunning assert.Equal(t, step.Running(), true) step.Failure = FailureIgnore step.State = StatusError assert.Equal(t, step.Failing(), true) step.State = StatusFailure assert.Equal(t, step.Failing(), true) step.Failure = FailureFail step.State = StatusError assert.Equal(t, step.Failing(), true) step.State = StatusFailure assert.Equal(t, step.Failing(), true) step.State = StatusPending assert.Equal(t, step.Failing(), false) step.State = StatusSuccess assert.Equal(t, step.Failing(), false) } ================================================ FILE: server/model/task.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "fmt" "slices" "go.woodpecker-ci.org/woodpecker/v3/pipeline" ) // Task defines scheduled pipeline Task. type Task struct { ID string `json:"id" xorm:"PK UNIQUE 'id'"` PID int `json:"pid" xorm:"'pid'"` Name string `json:"name" xorm:"'name'"` Data []byte `json:"-" xorm:"LONGBLOB 'data'"` Labels map[string]string `json:"labels" xorm:"json 'labels'"` Dependencies []string `json:"dependencies" xorm:"json 'dependencies'"` RunOn []string `json:"run_on" xorm:"json 'run_on'"` DepStatus map[string]StatusValue `json:"dep_status" xorm:"json 'dependencies_status'"` AgentID int64 `json:"agent_id" xorm:"'agent_id'"` PipelineID int64 `json:"pipeline_id" xorm:"'pipeline_id'"` RepoID int64 `json:"repo_id" xorm:"'repo_id'"` } // @name Task // TableName return database table name for xorm. func (Task) TableName() string { return "tasks" } func (t *Task) String() string { return fmt.Sprintf("%s (%s) - %s", t.ID, t.Dependencies, t.DepStatus) } func (t *Task) ApplyLabelsFromRepo(r *Repo) error { if r == nil { return fmt.Errorf("repo is nil but needed to get task labels") } if t.Labels == nil { t.Labels = make(map[string]string) } t.Labels[pipeline.LabelFilterRepo] = r.FullName t.Labels[pipeline.LabelFilterOrg] = fmt.Sprintf("%d", r.OrgID) return nil } // ShouldRun tells if a task should be run or skipped, based on dependencies. func (t *Task) ShouldRun() bool { if t.runsOnFailure() && t.runsOnSuccess() { return true } if !t.runsOnFailure() && t.runsOnSuccess() { for _, status := range t.DepStatus { if status != StatusSuccess { return false } } return true } if t.runsOnFailure() && !t.runsOnSuccess() { for _, status := range t.DepStatus { if status == StatusSuccess { return false } } return true } return false } func (t *Task) runsOnFailure() bool { return slices.Contains(t.RunOn, string(StatusFailure)) } func (t *Task) runsOnSuccess() bool { if len(t.RunOn) == 0 { return true } return slices.Contains(t.RunOn, string(StatusSuccess)) } ================================================ FILE: server/model/task_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline" ) func TestTask_GetLabels(t *testing.T) { t.Run("Nil Repo", func(t *testing.T) { task := &Task{} err := task.ApplyLabelsFromRepo(nil) assert.Error(t, err) assert.Nil(t, task.Labels) assert.EqualError(t, err, "repo is nil but needed to get task labels") }) t.Run("Empty Repo", func(t *testing.T) { task := &Task{} repo := &Repo{} err := task.ApplyLabelsFromRepo(repo) assert.NoError(t, err) assert.NotNil(t, task.Labels) assert.Equal(t, map[string]string{ pipeline.LabelFilterRepo: "", pipeline.LabelFilterOrg: "0", }, task.Labels) }) t.Run("Empty Labels", func(t *testing.T) { task := &Task{} repo := &Repo{ FullName: "test/repo", ID: 123, OrgID: 456, } err := task.ApplyLabelsFromRepo(repo) assert.NoError(t, err) assert.NotNil(t, task.Labels) assert.Equal(t, map[string]string{ pipeline.LabelFilterRepo: "test/repo", pipeline.LabelFilterOrg: "456", }, task.Labels) }) t.Run("Existing Labels", func(t *testing.T) { task := &Task{ Labels: map[string]string{ "existing": "label", }, } repo := &Repo{ FullName: "test/repo", ID: 123, OrgID: 456, } err := task.ApplyLabelsFromRepo(repo) assert.NoError(t, err) assert.NotNil(t, task.Labels) assert.Equal(t, map[string]string{ "existing": "label", pipeline.LabelFilterRepo: "test/repo", pipeline.LabelFilterOrg: "456", }, task.Labels) }) } ================================================ FILE: server/model/team.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Team represents a team or organization in the forge. type Team struct { // Login is the username for this team. Login string `json:"login"` // the avatar url for this team. Avatar string `json:"avatar_url"` } ================================================ FILE: server/model/user.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "errors" "regexp" ) // Validate a username (e.g. from github). var reUsername = regexp.MustCompile("^[a-zA-Z0-9-_.]+$") var errUserLoginInvalid = errors.New("invalid user login") const maxLoginLen = 250 // User represents a registered user. type User struct { // the id for this user. // // required: true ID int64 `json:"id" xorm:"pk autoincr 'id'"` ForgeID int64 `json:"forge_id,omitempty" xorm:"forge_id UNIQUE(forge_id) UNIQUE(forge_login)"` ForgeRemoteID ForgeRemoteID `json:"forge_remote_id" xorm:"forge_remote_id UNIQUE(forge_id)"` // Login is the username for this user. // // required: true Login string `json:"login" xorm:"'login' UNIQUE(forge_login)"` // AccessToken is the oauth2 access token. AccessToken string `json:"-" xorm:"TEXT 'access_token'"` // RefreshToken is the oauth2 refresh token. RefreshToken string `json:"-" xorm:"TEXT 'refresh_token'"` // Expiry is the AccessToken expiration timestamp (unix seconds). Expiry int64 `json:"-" xorm:"expiry"` // Email is the email address for this user. // // required: true Email string `json:"email" xorm:" varchar(500) 'email'"` // the avatar url for this user. Avatar string `json:"avatar_url" xorm:" varchar(500) 'avatar'"` // Admin indicates the user is a system administrator. // // NOTE: If the username is part of the WOODPECKER_ADMIN // environment variable, this value will be set to true on login. Admin bool `json:"admin,omitempty" xorm:"admin"` // Hash is a unique token used to sign tokens. Hash string `json:"-" xorm:"UNIQUE varchar(500) 'hash'"` // OrgID is the of the user as model.Org. OrgID int64 `json:"org_id" xorm:"org_id"` } // @name User // TableName return database table name for xorm. func (User) TableName() string { return "users" } // Validate validates the required fields and formats. func (u *User) Validate() error { switch { case len(u.Login) == 0: return errUserLoginInvalid case len(u.Login) > maxLoginLen: return errUserLoginInvalid case !reUsername.MatchString(u.Login): return errUserLoginInvalid default: return nil } } ================================================ FILE: server/model/user_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model import ( "testing" "github.com/stretchr/testify/assert" ) func TestUserValidate(t *testing.T) { tests := []struct { user User err error }{ { user: User{}, err: errUserLoginInvalid, }, { user: User{Login: "octocat!"}, err: errUserLoginInvalid, }, { user: User{Login: "!octocat"}, err: errUserLoginInvalid, }, { user: User{Login: "john$smith"}, err: errUserLoginInvalid, }, { user: User{Login: "octocat"}, err: nil, }, { user: User{Login: "john-smith"}, err: nil, }, { user: User{Login: "john_smith"}, err: nil, }, { user: User{Login: "john.smith"}, err: nil, }, } for _, test := range tests { err := test.user.Validate() assert.ErrorIs(t, err, test.err) } } ================================================ FILE: server/model/workflow.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package model // Workflow represents a workflow in the pipeline. type Workflow struct { ID int64 `json:"id" xorm:"pk autoincr 'id'"` PipelineID int64 `json:"pipeline_id" xorm:"UNIQUE(s) INDEX 'pipeline_id'"` PID int `json:"pid" xorm:"UNIQUE(s) 'pid'"` Name string `json:"name" xorm:"name"` State StatusValue `json:"state" xorm:"state"` Error string `json:"error,omitempty" xorm:"TEXT 'error'"` Started int64 `json:"started,omitempty" xorm:"started"` Finished int64 `json:"finished,omitempty" xorm:"finished"` AgentID int64 `json:"agent_id,omitempty" xorm:"agent_id"` Platform string `json:"platform,omitempty" xorm:"platform"` Environ map[string]string `json:"environ,omitempty" xorm:"json 'environ'"` AxisID int `json:"-" xorm:"axis_id"` Children []*Step `json:"children,omitempty" xorm:"-"` } // TableName return database table name for xorm. func (Workflow) TableName() string { return "workflows" } // Running returns true if the process state is pending or running. func (p *Workflow) Running() bool { return p.State == StatusPending || p.State == StatusRunning } // Failing returns true if the process state is failed, killed or error. func (p *Workflow) Failing() bool { return p.State == StatusError || p.State == StatusKilled || p.State == StatusFailure } // IsThereRunningStage determine if it contains workflows running or pending to run. // TODO: return false based on depends_on (https://github.com/woodpecker-ci/woodpecker/pull/730#discussion_r795681697) func IsThereRunningStage(workflows []*Workflow) bool { for _, p := range workflows { if p.Running() { return true } } return false } ================================================ FILE: server/pipeline/approve.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // Approve update the status to pending for a blocked pipeline so it can be executed. func Approve(ctx context.Context, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo) (*model.Pipeline, error) { if currentPipeline.Status != model.StatusBlocked { return nil, ErrBadRequest{Msg: fmt.Sprintf("cannot approve a pipeline with status %s", currentPipeline.Status)} } forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { msg := fmt.Sprintf("failure to load forge for repo '%s'", repo.FullName) log.Error().Err(err).Str("repo", repo.FullName).Msg(msg) return nil, errors.New(msg) } // fetch the pipeline file from the database configs, err := store.ConfigsForPipeline(currentPipeline.ID) if err != nil { msg := fmt.Sprintf("failure to get pipeline config for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, ErrNotFound{Msg: msg} } var yamls []*forge_types.FileMeta for _, y := range configs { yamls = append(yamls, &forge_types.FileMeta{Data: y.Data, Name: y.Name}) } if currentPipeline.Workflows, err = store.WorkflowGetTree(currentPipeline); err != nil { return nil, fmt.Errorf("error: loading workflows. %w", err) } if currentPipeline, err = UpdateToStatusPending(store, *currentPipeline, user.Login); err != nil { return nil, fmt.Errorf("error updating pipeline. %w", err) } for _, wf := range currentPipeline.Workflows { if wf.State != model.StatusBlocked { continue } wf.State = model.StatusPending if err := store.WorkflowUpdate(wf); err != nil { return nil, fmt.Errorf("error updating workflow. %w", err) } for _, step := range wf.Children { if step.State != model.StatusBlocked { continue } step.State = model.StatusPending if err := store.StepUpdate(step); err != nil { return nil, fmt.Errorf("error updating step. %w", err) } } } currentPipeline, pipelineItems, err := createPipelineItems(ctx, forge, store, currentPipeline, user, repo, yamls, nil) if err != nil { msg := fmt.Sprintf("failure to createPipelineItems for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } // we have no way to link old workflows and steps in database to new engine generated steps, // so we just delete the old and insert the new ones if err := store.WorkflowsReplace(currentPipeline, currentPipeline.Workflows); err != nil { log.Error().Err(err).Str("repo", repo.FullName).Msgf("error persisting new steps for %s#%d after approval", repo.FullName, currentPipeline.Number) return nil, err } publishPipeline(ctx, forge, currentPipeline, repo, user) currentPipeline, err = start(ctx, forge, store, currentPipeline, user, repo, pipelineItems) if err != nil { msg := fmt.Sprintf("failure to start pipeline for %s: %v", repo.FullName, err) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } return currentPipeline, nil } ================================================ FILE: server/pipeline/cancel.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "slices" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/queue" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // Cancel the pipeline and returns the status. func Cancel(ctx context.Context, _forge forge.Forge, store store.Store, repo *model.Repo, user *model.User, pipeline *model.Pipeline, cancelInfo *model.CancelInfo) error { if pipeline.Status != model.StatusRunning && pipeline.Status != model.StatusPending && pipeline.Status != model.StatusBlocked { return &ErrBadRequest{Msg: "Cannot cancel a non-running or non-pending or non-blocked pipeline"} } workflows, err := store.WorkflowGetTree(pipeline) if err != nil { return &ErrNotFound{Msg: err.Error()} } // First cancel/evict workflows in the queue in one go var workflowsToCancel []string for _, w := range workflows { if w.State == model.StatusRunning || w.State == model.StatusPending { workflowsToCancel = append(workflowsToCancel, fmt.Sprint(w.ID)) } } if len(workflowsToCancel) != 0 { if err := server.Config.Services.Scheduler.ErrorAtOnce(ctx, workflowsToCancel, queue.ErrCancel); err != nil { log.Error().Err(err).Msgf("queue: evict_at_once: %v", workflowsToCancel) } } hasPendingOnly := true // Then update the DB status for pending pipelines // Running ones will be set when the agents stop on the cancel signal for _, workflow := range workflows { if workflow.State == model.StatusPending { if _, err = UpdateWorkflowToStatusSkipped(store, *workflow); err != nil { log.Error().Err(err).Msgf("cannot update workflow with id %d state", workflow.ID) } } else { hasPendingOnly = false } for _, step := range workflow.Children { if step.State == model.StatusPending { if _, err = UpdateStepToStatusSkipped(store, *step, 0, model.StatusCanceled); err != nil { log.Error().Err(err).Msgf("cannot update workflow with id %d state", workflow.ID) } } } } plState := model.StatusKilled if hasPendingOnly { plState = model.StatusCanceled } killedPipeline, err := UpdateToStatusKilled(store, *pipeline, cancelInfo, plState) if err != nil { log.Error().Err(err).Msgf("UpdateToStatusKilled: %v", pipeline) return err } updatePipelineStatus(ctx, _forge, killedPipeline, repo, user) if killedPipeline.Workflows, err = store.WorkflowGetTree(killedPipeline); err != nil { return err } if err := publishToTopic(ctx, killedPipeline, repo); err != nil { log.Error().Err(err).Msg("could not push pipeline status change to pubsub provider") } return nil } func cancelPreviousPipelines( ctx context.Context, _forge forge.Forge, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, user *model.User, ) error { // check this event should cancel previous pipelines eventIncluded := slices.Contains(repo.CancelPreviousPipelineEvents, pipeline.Event) if !eventIncluded { return nil } // get all active activeBuilds activeBuilds, err := _store.GetActivePipelineList(repo) if err != nil { return err } pipelineNeedsCancel := func(active *model.Pipeline) bool { // always filter on same event if active.Event != pipeline.Event { return false } // find events for the same context switch pipeline.Event { case model.EventPush: return pipeline.Branch == active.Branch default: return pipeline.Refspec == active.Refspec } } for _, active := range activeBuilds { if active.ID == pipeline.ID { // same pipeline. e.g. self continue } cancel := pipelineNeedsCancel(active) if !cancel { continue } if err = Cancel(ctx, _forge, _store, repo, user, active, &model.CancelInfo{ SupersededBy: pipeline.Number, }); err != nil { log.Error(). Err(err). Str("ref", active.Ref). Int64("id", active.ID). Msg("failed to cancel pipeline") } } return nil } ================================================ FILE: server/pipeline/config.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func findOrPersistPipelineConfig(store store.Store, currentPipeline *model.Pipeline, forgeYamlConfig *forge_types.FileMeta) (*model.Config, error) { return store.ConfigPersist(&model.Config{ RepoID: currentPipeline.RepoID, Name: step_builder.SanitizePath(forgeYamlConfig.Name), Data: forgeYamlConfig.Data, }) } ================================================ FILE: server/pipeline/create.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/constraint" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/version" ) // Create a new pipeline and start it. func Create(ctx context.Context, _store store.Store, repo *model.Repo, pipeline *model.Pipeline) (*model.Pipeline, error) { repoUser, err := _store.GetUser(repo.UserID) if err != nil { msg := fmt.Sprintf("failure to find repo owner via id '%d'", repo.UserID) log.Error().Err(err).Str("repo", repo.FullName).Msg(msg) return nil, errors.New(msg) } if constraint.IsSkipCommitMessage(metadata.Event(pipeline.Event), pipeline.Message) { ref := pipeline.Commit if len(ref) == 0 { ref = pipeline.Ref } log.Debug().Str("repo", repo.FullName).Msgf("ignoring pipeline as skip-ci was found in the commit (%s) message '%s'", ref, pipeline.Message) return nil, ErrFiltered } _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { msg := fmt.Sprintf("failure to load forge for repo '%s'", repo.FullName) log.Error().Err(err).Str("repo", repo.FullName).Msg(msg) return nil, errors.New(msg) } // If the forge has a refresh token, the current access token // may be stale. Therefore, we should refresh prior to dispatching // the pipeline. forge.Refresh(ctx, _forge, _store, repoUser) // update some pipeline fields pipeline.RepoID = repo.ID pipeline.Status = model.StatusCreated pipeline.Version = version.String() setApprovalState(repo, pipeline) err = _store.CreatePipeline(pipeline) if err != nil { msg := fmt.Errorf("failed to save pipeline for %s", repo.FullName) log.Error().Str("repo", repo.FullName).Err(err).Msg(msg.Error()) return nil, msg } // fetch the pipeline file from the forge configService := server.Config.Services.Manager.ConfigServiceFromRepo(repo) forgeYamlConfigs, configFetchErr := configService.Fetch(ctx, _forge, repoUser, repo, pipeline, nil, false) switch { case errors.Is(configFetchErr, &forge_types.ErrConfigNotFound{}): log.Debug().Str("repo", repo.FullName).Err(configFetchErr).Msgf("cannot find config '%s' in '%s' with user: '%s'", repo.Config, pipeline.Ref, repoUser.Login) if err := _store.DeletePipeline(pipeline); err != nil { log.Error().Str("repo", repo.FullName).Err(err).Msg("failed to delete pipeline without config") } return nil, ErrFiltered case configFetchErr != nil && forgeYamlConfigs != nil: // unexpected status code from config endpoint - using previous config as fallback log.Warn().Str("repo", repo.FullName).Err(configFetchErr).Msgf("error while fetching config '%s' in '%s' with user: '%s', will fallback to old config", repo.Config, pipeline.Ref, repoUser.Login) case configFetchErr != nil: // error while fetching config - not using the old config log.Error().Str("repo", repo.FullName).Err(configFetchErr).Msgf("error while fetching config '%s' in '%s' with user: '%s', and did not get any config", repo.Config, pipeline.Ref, repoUser.Login) return nil, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, fmt.Errorf("could not load config from forge: %w", configFetchErr)) } pipelineItems, parseErr := parsePipeline(ctx, _forge, _store, pipeline, repoUser, repo, forgeYamlConfigs, nil) if pipeline_errors.HasBlockingErrors(parseErr) { log.Debug().Str("repo", repo.FullName).Err(parseErr).Msg("failed to parse yaml") return pipeline, updatePipelineWithErr(ctx, _forge, _store, pipeline, repo, repoUser, parseErr) } else if parseErr != nil { pipeline.Errors = pipeline_errors.GetPipelineErrors(parseErr) } if len(pipelineItems) == 0 { log.Debug().Str("repo", repo.FullName).Msg(ErrFiltered.Error()) if err := _store.DeletePipeline(pipeline); err != nil { log.Error().Str("repo", repo.FullName).Err(err).Msg("failed to delete empty pipeline") } return nil, ErrFiltered } pipeline = setPipelineStepsOnPipeline(pipeline, pipelineItems) // persist the pipeline config for historical correctness, restarts, etc var configs []*model.Config for _, forgeYamlConfig := range forgeYamlConfigs { config, err := findOrPersistPipelineConfig(_store, pipeline, forgeYamlConfig) if err != nil { msg := fmt.Sprintf("failed to find or persist pipeline config for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } configs = append(configs, config) } // link pipeline to persisted configs if err := linkPipelineConfigs(_store, configs, pipeline.ID); err != nil { msg := fmt.Sprintf("failed to find or persist pipeline config for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } if err := prepareStart(ctx, _forge, _store, pipeline, repoUser, repo); err != nil { log.Error().Err(err).Str("repo", repo.FullName).Msgf("error preparing pipeline for %s#%d", repo.FullName, pipeline.Number) return nil, err } if pipeline.Status == model.StatusBlocked { return pipeline, nil } if err := updatePipelinePending(ctx, _forge, _store, pipeline, repo, repoUser); err != nil { return nil, err } pipeline, err = start(ctx, _forge, _store, pipeline, repoUser, repo, pipelineItems) if err != nil { msg := fmt.Sprintf("failed to start pipeline for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } return pipeline, nil } func updatePipelineWithErr(ctx context.Context, _forge forge.Forge, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User, err error) error { _pipeline, err := UpdateToStatusError(_store, *pipeline, err) if err != nil { return err } // update value in ref *pipeline = *_pipeline publishPipeline(ctx, _forge, pipeline, repo, repoUser) return nil } func updatePipelinePending(ctx context.Context, _forge forge.Forge, _store store.Store, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User) error { _pipeline, err := UpdateToStatusPending(_store, *pipeline, "") if err != nil { return err } // update value in ref *pipeline = *_pipeline publishPipeline(ctx, _forge, pipeline, repo, repoUser) return nil } ================================================ FILE: server/pipeline/decline.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // Decline updates the status to declined for blocked pipelines. func Decline(ctx context.Context, store store.Store, pipeline *model.Pipeline, user *model.User, repo *model.Repo) (*model.Pipeline, error) { forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { msg := fmt.Sprintf("failure to load forge for repo '%s'", repo.FullName) log.Error().Err(err).Str("repo", repo.FullName).Msg(msg) return nil, errors.New(msg) } if pipeline.Status != model.StatusBlocked { return nil, fmt.Errorf("cannot decline a pipeline with status %s", pipeline.Status) } pipeline, err = UpdateToStatusDeclined(store, *pipeline, user.Login) if err != nil { return nil, fmt.Errorf("error updating pipeline. %w", err) } if pipeline.Workflows, err = store.WorkflowGetTree(pipeline); err != nil { log.Error().Err(err).Msg("cannot build tree from step list") } for _, wf := range pipeline.Workflows { wf.State = model.StatusDeclined if err := store.WorkflowUpdate(wf); err != nil { return nil, fmt.Errorf("error updating workflow. %w", err) } for _, step := range wf.Children { step.State = model.StatusDeclined if err := store.StepUpdate(step); err != nil { return nil, fmt.Errorf("error updating step. %w", err) } } } updatePipelineStatus(ctx, forge, pipeline, repo, user) if err := publishToTopic(ctx, pipeline, repo); err != nil { log.Error().Err(err).Msg("could not push pipeline status change to pubsub provider") } return pipeline, nil } ================================================ FILE: server/pipeline/errors.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import "errors" type ErrNotFound struct { Msg string } func (e ErrNotFound) Error() string { return e.Msg } func (e ErrNotFound) Is(target error) bool { _, ok := target.(ErrNotFound) if !ok { _, ok = target.(*ErrNotFound) } return ok } type ErrBadRequest struct { Msg string } func (e ErrBadRequest) Error() string { return e.Msg } func (e ErrBadRequest) Is(target error) bool { _, ok := target.(ErrBadRequest) if !ok { _, ok = target.(*ErrBadRequest) } return ok } var ErrFiltered = errors.New("ignoring hook: 'when' filters filtered out all steps") ================================================ FILE: server/pipeline/gated.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "slices" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func setApprovalState(repo *model.Repo, pipeline *model.Pipeline) { if !needsApproval(repo, pipeline) { return } // set pipeline status to blocked and require approval pipeline.Status = model.StatusBlocked } func needsApproval(repo *model.Repo, pipeline *model.Pipeline) bool { // skip events created by woodpecker itself if pipeline.Event == model.EventCron || pipeline.Event == model.EventManual { return false } // skip if user is allowed // It's enough to check the username as the repo matches the forge of the pipeline already (no username clashes from different forges possible) if slices.Contains(repo.ApprovalAllowedUsers, pipeline.Author) { return false } switch repo.RequireApproval { // repository allows all events without approval case model.RequireApprovalNone: return false // repository requires approval for pull requests from forks case model.RequireApprovalForks: if pipeline.IsPullRequest() && pipeline.FromFork { return true } // repository requires approval for pull requests case model.RequireApprovalPullRequests: if pipeline.IsPullRequest() { return true } // repository requires approval for all events case model.RequireApprovalAllEvents: return true } return false } ================================================ FILE: server/pipeline/gated_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestSetGatedState(t *testing.T) { t.Parallel() testCases := []struct { name string repo *model.Repo pipeline *model.Pipeline expectBlocked bool }{ { name: "by-pass for cron", repo: &model.Repo{ RequireApproval: model.RequireApprovalAllEvents, }, pipeline: &model.Pipeline{ Event: model.EventCron, }, expectBlocked: false, }, { name: "by-pass for manual pipeline", repo: &model.Repo{ RequireApproval: model.RequireApprovalAllEvents, }, pipeline: &model.Pipeline{ Event: model.EventManual, }, expectBlocked: false, }, { name: "require approval for fork PRs", repo: &model.Repo{ RequireApproval: model.RequireApprovalForks, }, pipeline: &model.Pipeline{ Event: model.EventPull, FromFork: true, }, expectBlocked: true, }, { name: "require approval for PRs", repo: &model.Repo{ RequireApproval: model.RequireApprovalPullRequests, }, pipeline: &model.Pipeline{ Event: model.EventPull, FromFork: false, }, expectBlocked: true, }, { name: "require approval for edited PRs", repo: &model.Repo{ RequireApproval: model.RequireApprovalPullRequests, }, pipeline: &model.Pipeline{ Event: model.EventPullMetadata, FromFork: false, }, expectBlocked: true, }, { name: "require approval for everything", repo: &model.Repo{ RequireApproval: model.RequireApprovalAllEvents, }, pipeline: &model.Pipeline{ Event: model.EventPush, }, expectBlocked: true, }, { name: "require approval for everything with allowed user", repo: &model.Repo{ RequireApproval: model.RequireApprovalAllEvents, ApprovalAllowedUsers: []string{"user"}, }, pipeline: &model.Pipeline{ Event: model.EventPush, Author: "user", }, expectBlocked: false, }, } for _, tc := range testCases { setApprovalState(tc.repo, tc.pipeline) assert.Equal(t, tc.expectBlocked, tc.pipeline.Status == model.StatusBlocked) } } ================================================ FILE: server/pipeline/helper.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func updatePipelineStatus(ctx context.Context, forge forge.Forge, pipeline *model.Pipeline, repo *model.Repo, user *model.User) { for _, workflow := range pipeline.Workflows { err := forge.Status(ctx, user, repo, pipeline, workflow) if err != nil { log.Error().Err(err).Msgf("error setting commit status for %s/%d", repo.FullName, pipeline.Number) return } } } ================================================ FILE: server/pipeline/items.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "database/sql" "errors" "maps" "github.com/rs/zerolog/log" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" pipeline_metadata "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func parsePipeline(ctx context.Context, forge forge.Forge, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo, yamls []*forge_types.FileMeta, envs map[string]string) ([]*step_builder.Item, error) { netrc, err := forge.Netrc(user, repo) if err != nil { log.Error().Err(err).Msg("failed to generate netrc file") } // get the previous pipeline so that we can send status change notifications prev, err := store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID) if err != nil && !errors.Is(err, sql.ErrNoRows) { log.Error().Err(err).Str("repo", repo.FullName).Msgf("error getting last pipeline before pipeline number '%d'", currentPipeline.Number) } secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo) secs, err := secretService.SecretListPipeline(ctx, repo, currentPipeline, netrc) if err != nil { log.Error().Err(err).Msgf("error getting secrets for %s#%d", repo.FullName, currentPipeline.Number) } var secrets []compiler.Secret for _, sec := range secs { var events []pipeline_metadata.Event for _, event := range sec.Events { events = append(events, pipeline_metadata.Event(event)) } secrets = append(secrets, compiler.Secret{ Name: sec.Name, Value: sec.Value, AllowedPlugins: sec.Images, Events: events, }) } registryService := server.Config.Services.Manager.RegistryServiceFromRepo(repo) regs, err := registryService.RegistryListPipeline(ctx, repo, currentPipeline, netrc) if err != nil { log.Error().Err(err).Msgf("error getting registry credentials for %s#%d", repo.FullName, currentPipeline.Number) } var registries []compiler.Registry for _, reg := range regs { registries = append(registries, compiler.Registry{ Hostname: reg.Address, Username: reg.Username, Password: reg.Password, }) } if envs == nil { envs = map[string]string{} } environmentService := server.Config.Services.Manager.EnvironmentService() if environmentService != nil { globals, _ := environmentService.EnvironList(repo) for _, global := range globals { envs[global.Name] = global.Value } } maps.Copy(envs, currentPipeline.AdditionalVariables) b := step_builder.StepBuilder{ Repo: repo, Curr: currentPipeline, Prev: prev, Envs: envs, Host: server.Config.Server.Host, Yamls: yamls, Forge: forge, TrustedClonePlugins: append(repo.NetrcTrustedPlugins, server.Config.Pipeline.TrustedClonePlugins...), PrivilegedPlugins: server.Config.Pipeline.PrivilegedPlugins, RepoTrusted: &pipeline_metadata.TrustedConfiguration{ Network: repo.Trusted.Network, Volumes: repo.Trusted.Volumes, Security: repo.Trusted.Security, }, DefaultLabels: server.Config.Pipeline.DefaultWorkflowLabels, CompilerOptions: []compiler.Option{ compiler.WithLocal(false), compiler.WithRegistry(registries...), compiler.WithSecret(secrets...), compiler.WithProxy(compiler.ProxyOptions{ NoProxy: server.Config.Pipeline.Proxy.No, HTTPProxy: server.Config.Pipeline.Proxy.HTTP, HTTPSProxy: server.Config.Pipeline.Proxy.HTTPS, }), compiler.WithVolumes(server.Config.Pipeline.Volumes...), compiler.WithNetworks(server.Config.Pipeline.Networks...), compiler.WithOption( compiler.WithNetrc( netrc.Login, netrc.Password, netrc.Machine, ), repo.IsSCMPrivate || server.Config.Pipeline.AuthenticatePublicRepos, ), compiler.WithDefaultClonePlugin(server.Config.Pipeline.DefaultClonePlugin), compiler.WithWorkspaceFromURL(compiler.DefaultWorkspaceBase, repo.ForgeURL), }, } // TODO: remove with version 4.x if server.Config.Pipeline.ForceIgnoreServiceFailure { b.CompilerOptions = append(b.CompilerOptions, compiler.WithForceIgnoreServiceFailure()) } return b.Build() } func createPipelineItems(c context.Context, forge forge.Forge, store store.Store, currentPipeline *model.Pipeline, user *model.User, repo *model.Repo, yamls []*forge_types.FileMeta, envs map[string]string, ) (*model.Pipeline, []*step_builder.Item, error) { pipelineItems, err := parsePipeline(c, forge, store, currentPipeline, user, repo, yamls, envs) if pipeline_errors.HasBlockingErrors(err) { currentPipeline, uErr := UpdateToStatusError(store, *currentPipeline, err) if uErr != nil { log.Error().Err(uErr).Msgf("error setting error status of pipeline for %s#%d", repo.FullName, currentPipeline.Number) } else { updatePipelineStatus(c, forge, currentPipeline, repo, user) } return currentPipeline, nil, err } else if err != nil { currentPipeline.Errors = pipeline_errors.GetPipelineErrors(err) err = updatePipelinePending(c, forge, store, currentPipeline, repo, user) } currentPipeline = setPipelineStepsOnPipeline(currentPipeline, pipelineItems) return currentPipeline, pipelineItems, err } // setPipelineStepsOnPipeline is the link between pipeline representation in "pipeline package" and server // to be specific this func currently is used to convert the pipeline.Item list (crafted by StepBuilder.Build()) into // a pipeline that can be stored in the database by the server. func setPipelineStepsOnPipeline(pipeline *model.Pipeline, pipelineItems []*step_builder.Item) *model.Pipeline { var pidSequence int for _, item := range pipelineItems { if pidSequence < item.Workflow.PID { pidSequence = item.Workflow.PID } } // the workflows in the pipeline should be empty as only we do populate them, // but if a pipeline was already loaded form database it might contain things, so we just clean it pipeline.Workflows = nil for _, item := range pipelineItems { for _, stage := range item.Config.Stages { for _, step := range stage.Steps { pidSequence++ step := &model.Step{ Name: step.Name, UUID: step.UUID, PipelineID: pipeline.ID, PID: pidSequence, PPID: item.Workflow.PID, State: model.StatusPending, Failure: step.Failure, Type: model.StepType(step.Type), } if pipeline.Status == model.StatusBlocked { step.State = model.StatusBlocked } item.Workflow.Children = append(item.Workflow.Children, step) } } if pipeline.Status == model.StatusBlocked { item.Workflow.State = model.StatusBlocked } item.Workflow.PipelineID = pipeline.ID pipeline.Workflows = append(pipeline.Workflows, item.Workflow) } return pipeline } ================================================ FILE: server/pipeline/items_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" "go.woodpecker-ci.org/woodpecker/v3/server" forge_mocks "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder" manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks" registry_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks" secret_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestSetPipelineStepsOnPipeline(t *testing.T) { t.Parallel() pipeline := &model.Pipeline{ ID: 1, Event: model.EventPush, } pipelineItems := []*step_builder.Item{{ Workflow: &model.Workflow{ PID: 1, }, Config: &backend_types.Config{ Stages: []*backend_types.Stage{ { Steps: []*backend_types.Step{ { Name: "clone", }, }, }, { Steps: []*backend_types.Step{ { Name: "step", }, }, }, }, }, }} pipeline = setPipelineStepsOnPipeline(pipeline, pipelineItems) if len(pipeline.Workflows) != 1 { t.Fatal("Should generate three in total") } if pipeline.Workflows[0].PipelineID != 1 { t.Fatal("Should set workflow's pipeline ID") } if pipeline.Workflows[0].Children[0].PPID != 1 { t.Fatal("Should set step PPID") } } func TestParsePipeline(t *testing.T) { t.Parallel() pipeline := &model.Pipeline{ ID: 1, Event: model.EventPush, AdditionalVariables: map[string]string{ "ADDITIONAL": "value", }, } user := &model.User{ ID: 1, } repo := &model.Repo{ ID: 1, } yamls := []*forge_types.FileMeta{ { Name: "woodpecker.yml", Data: []byte(` when: - event: push steps: - name: test image: alpine environment: HELLO: from_secret: hello commands: - echo "hello world" `), }, } envs := map[string]string{ "FOO": "bar", } forge := forge_mocks.NewMockForge(t) forge.On("Netrc", mock.Anything, mock.Anything).Return(&model.Netrc{ Login: "user", Password: "password", }, nil) forge.On("Name").Return("github") forge.On("URL").Return("https://github.com") store := store_mocks.NewMockStore(t) store.On("GetPipelineLastBefore", mock.Anything, mock.Anything, pipeline.ID).Return(&model.Pipeline{}, nil) mockManager := manager_mocks.NewMockManager(t) server.Config.Services.Manager = mockManager secretService := secret_service_mocks.NewMockService(t) secretService.On("SecretListPipeline", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{ { Name: "hello", Value: "secret world", }, }, nil) mockManager.On("SecretServiceFromRepo", mock.Anything).Return(secretService, nil) registryService := registry_service_mocks.NewMockService(t) registryService.On("RegistryListPipeline", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Registry{ { Address: "docker.io", Username: "user", Password: "password", }, }, nil) mockManager.On("RegistryServiceFromRepo", mock.Anything).Return(registryService, nil) mockManager.On("EnvironmentService").Return(nil, nil) pipelineItems, err := parsePipeline(t.Context(), forge, store, pipeline, user, repo, yamls, envs) assert.NoError(t, err) assert.Len(t, pipelineItems, 1) assert.Equal(t, "test", pipelineItems[0].Config.Stages[0].Steps[0].Name) assert.Equal(t, "alpine", pipelineItems[0].Config.Stages[0].Steps[0].Image) step := pipelineItems[0].Config.Stages[0].Steps[0] assert.Equal(t, []string{`echo "hello world"`}, step.Commands) assert.Equal(t, "value", step.Environment["ADDITIONAL"]) assert.Equal(t, "bar", step.Environment["FOO"]) assert.Equal(t, "secret world", step.Environment["HELLO"]) } ================================================ FILE: server/pipeline/pipeline_status.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2019 mhmxs. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "time" "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // PipelineStatus determine pipeline status based on corresponding workflow list. func PipelineStatus(workflows []*model.Workflow) model.StatusValue { status := model.StatusSuccess for _, p := range workflows { status = MergeStatusValues(status, p.State) } return status } func UpdateToStatusRunning(store store.Store, pipeline model.Pipeline, started int64) (*model.Pipeline, error) { pipeline.Status = model.StatusRunning pipeline.Started = started return &pipeline, store.UpdatePipeline(&pipeline) } func UpdateToStatusPending(store store.Store, pipeline model.Pipeline, reviewer string) (*model.Pipeline, error) { if reviewer != "" { pipeline.Reviewer = reviewer pipeline.Reviewed = time.Now().Unix() } pipeline.Status = model.StatusPending return &pipeline, store.UpdatePipeline(&pipeline) } func UpdateToStatusDeclined(store store.Store, pipeline model.Pipeline, reviewer string) (*model.Pipeline, error) { pipeline.Reviewer = reviewer pipeline.Status = model.StatusDeclined pipeline.Reviewed = time.Now().Unix() return &pipeline, store.UpdatePipeline(&pipeline) } func UpdateStatusToDone(store store.Store, pipeline model.Pipeline, status model.StatusValue, stopped int64) (*model.Pipeline, error) { pipeline.Status = status pipeline.Finished = stopped return &pipeline, store.UpdatePipeline(&pipeline) } func UpdateToStatusError(store store.Store, pipeline model.Pipeline, err error) (*model.Pipeline, error) { pipeline.Errors = errors.GetPipelineErrors(err) pipeline.Status = model.StatusError pipeline.Started = time.Now().Unix() pipeline.Finished = pipeline.Started return &pipeline, store.UpdatePipeline(&pipeline) } func UpdateToStatusKilled(store store.Store, pipeline model.Pipeline, cancelInfo *model.CancelInfo, state model.StatusValue) (*model.Pipeline, error) { pipeline.Status = state pipeline.Finished = time.Now().Unix() pipeline.CancelInfo = cancelInfo return &pipeline, store.UpdatePipeline(&pipeline) } ================================================ FILE: server/pipeline/pipeline_status_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2019 mhmxs. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func mockStorePipeline(t *testing.T) store.Store { s := mocks.NewMockStore(t) s.On("UpdatePipeline", mock.Anything).Return(nil) return s } func TestUpdateToStatusRunning(t *testing.T) { t.Parallel() pipeline, _ := UpdateToStatusRunning(mockStorePipeline(t), model.Pipeline{}, int64(1)) assert.Equal(t, model.StatusRunning, pipeline.Status) assert.EqualValues(t, 1, pipeline.Started) } func TestUpdateToStatusPending(t *testing.T) { t.Parallel() now := time.Now().Unix() pipeline, _ := UpdateToStatusPending(mockStorePipeline(t), model.Pipeline{}, "Reviewer") assert.Equal(t, model.StatusPending, pipeline.Status) assert.Equal(t, "Reviewer", pipeline.Reviewer) assert.LessOrEqual(t, now, pipeline.Reviewed) } func TestUpdateToStatusDeclined(t *testing.T) { t.Parallel() now := time.Now().Unix() pipeline, _ := UpdateToStatusDeclined(mockStorePipeline(t), model.Pipeline{}, "Reviewer") assert.Equal(t, model.StatusDeclined, pipeline.Status) assert.Equal(t, "Reviewer", pipeline.Reviewer) assert.LessOrEqual(t, now, pipeline.Reviewed) } func TestUpdateToStatusToDone(t *testing.T) { t.Parallel() pipeline, _ := UpdateStatusToDone(mockStorePipeline(t), model.Pipeline{}, "status", int64(1)) assert.Equal(t, model.StatusValue("status"), pipeline.Status) assert.EqualValues(t, 1, pipeline.Finished) } func TestUpdateToStatusError(t *testing.T) { t.Parallel() now := time.Now().Unix() pipeline, _ := UpdateToStatusError(mockStorePipeline(t), model.Pipeline{}, errors.New("this is an error")) assert.Len(t, pipeline.Errors, 1) assert.Equal(t, "[generic] this is an error", pipeline.Errors[0].Error()) assert.Equal(t, model.StatusError, pipeline.Status) assert.Equal(t, pipeline.Started, pipeline.Finished) assert.LessOrEqual(t, now, pipeline.Started) assert.Equal(t, pipeline.Started, pipeline.Finished) } func TestUpdateToStatusKilled(t *testing.T) { t.Parallel() now := time.Now().Unix() cancelInfo := &model.CancelInfo{ SupersededBy: 2, } pipeline, _ := UpdateToStatusKilled(mockStorePipeline(t), model.Pipeline{}, cancelInfo, model.StatusKilled) assert.Equal(t, model.StatusKilled, pipeline.Status) assert.NotNil(t, pipeline.CancelInfo) assert.EqualValues(t, 2, pipeline.CancelInfo.SupersededBy) assert.LessOrEqual(t, now, pipeline.Finished) } ================================================ FILE: server/pipeline/queue.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "encoding/json" "fmt" "maps" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder" ) func queuePipeline(ctx context.Context, repo *model.Repo, pipelineItems []*step_builder.Item) error { var tasks []*model.Task for _, item := range pipelineItems { if item.Workflow.State == model.StatusSkipped { continue } task := &model.Task{ ID: fmt.Sprint(item.Workflow.ID), PID: item.Workflow.PID, Name: item.Workflow.Name, Labels: make(map[string]string), PipelineID: item.Workflow.PipelineID, RepoID: repo.ID, } maps.Copy(task.Labels, item.Labels) err := task.ApplyLabelsFromRepo(repo) if err != nil { return err } task.Dependencies = getTaskDependencies(item.DependsOn, pipelineItems) task.RunOn = item.RunsOn task.DepStatus = make(map[string]model.StatusValue) task.Data, err = json.Marshal(rpc.Workflow{ ID: fmt.Sprint(item.Workflow.ID), Config: item.Config, Timeout: repo.Timeout, }) if err != nil { return err } tasks = append(tasks, task) } return server.Config.Services.Scheduler.PushAtOnce(ctx, tasks) } func getTaskDependencies(dependsOn []string, items []*step_builder.Item) (taskIDs []string) { for _, dep := range dependsOn { for _, pipelineItem := range items { if pipelineItem.Workflow.Name == dep { taskIDs = append(taskIDs, fmt.Sprint(pipelineItem.Workflow.ID)) } } } return taskIDs } ================================================ FILE: server/pipeline/restart.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/version" ) // Restart a pipeline by creating a new one out of the old and start it. func Restart(ctx context.Context, store store.Store, lastPipeline *model.Pipeline, user *model.User, repo *model.Repo, envs map[string]string) (*model.Pipeline, error) { forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { msg := fmt.Sprintf("failure to load forge for repo '%s'", repo.FullName) log.Error().Err(err).Str("repo", repo.FullName).Msg(msg) return nil, errors.New(msg) } if lastPipeline.Status == model.StatusBlocked { return nil, &ErrBadRequest{Msg: "cannot restart a pipeline with status blocked"} } // fetch the old pipeline config from the database configs, err := store.ConfigsForPipeline(lastPipeline.ID) if err != nil { log.Error().Err(err).Msgf("failure to get pipeline config for %s", repo.FullName) return nil, &ErrNotFound{Msg: fmt.Sprintf("failure to get pipeline config for %s. %s", repo.FullName, err)} } var pipelineFiles []*forge_types.FileMeta for _, y := range configs { pipelineFiles = append(pipelineFiles, &forge_types.FileMeta{Data: y.Data, Name: y.Name}) } // If the config service is active we should refetch the config in case something changed configService := server.Config.Services.Manager.ConfigServiceFromRepo(repo) pipelineFiles, err = configService.Fetch(ctx, forge, user, repo, lastPipeline, pipelineFiles, true) if err != nil { return nil, &ErrBadRequest{ Msg: fmt.Sprintf("On fetching external pipeline config: %s", err), } } newPipeline := createNewOutOfOld(lastPipeline) newPipeline.Parent = lastPipeline.Number newPipeline.Version = version.String() err = store.CreatePipeline(newPipeline) if err != nil { msg := fmt.Sprintf("failure to save pipeline for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } if len(configs) == 0 { newPipeline, uErr := UpdateToStatusError(store, *newPipeline, errors.New("pipeline definition not found")) if uErr != nil { log.Debug().Err(uErr).Msg("failure to update pipeline status") } else { updatePipelineStatus(ctx, forge, newPipeline, repo, user) } return newPipeline, nil } if err := linkPipelineConfigs(store, configs, newPipeline.ID); err != nil { msg := fmt.Sprintf("failure to persist pipeline config for %s.", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } newPipeline, pipelineItems, err := createPipelineItems(ctx, forge, store, newPipeline, user, repo, pipelineFiles, envs) if err != nil { msg := fmt.Sprintf("failure to createPipelineItems for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } if err := prepareStart(ctx, forge, store, newPipeline, user, repo); err != nil { msg := fmt.Sprintf("failure to prepare pipeline for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } newPipeline, err = start(ctx, forge, store, newPipeline, user, repo, pipelineItems) if err != nil { msg := fmt.Sprintf("failure to start pipeline for %s", repo.FullName) log.Error().Err(err).Msg(msg) return nil, errors.New(msg) } return newPipeline, nil } func linkPipelineConfigs(store store.Store, configs []*model.Config, pipelineID int64) error { for _, conf := range configs { pipelineConfig := &model.PipelineConfig{ ConfigID: conf.ID, PipelineID: pipelineID, } err := store.PipelineConfigCreate(pipelineConfig) if err != nil { return err } } return nil } func createNewOutOfOld(old *model.Pipeline) *model.Pipeline { newPipeline := *old newPipeline.ID = 0 newPipeline.Number = 0 newPipeline.Status = model.StatusPending newPipeline.Started = 0 newPipeline.Finished = 0 newPipeline.Errors = nil return &newPipeline } ================================================ FILE: server/pipeline/start.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline/step_builder" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // start a pipeline, make sure it was stored persistent in the store before. func start(ctx context.Context, forge forge.Forge, store store.Store, activePipeline *model.Pipeline, user *model.User, repo *model.Repo, pipelineItems []*step_builder.Item) (*model.Pipeline, error) { // call to cancel previous pipelines if needed if err := cancelPreviousPipelines(ctx, forge, store, activePipeline, repo, user); err != nil { // should be not breaking log.Error().Err(err).Msg("failed to cancel previous pipelines") } publishPipeline(ctx, forge, activePipeline, repo, user) if err := queuePipeline(ctx, repo, pipelineItems); err != nil { log.Error().Err(err).Msg("queuePipeline") return nil, err } return activePipeline, nil } func prepareStart(ctx context.Context, forge forge.Forge, store store.Store, activePipeline *model.Pipeline, user *model.User, repo *model.Repo) error { if err := store.WorkflowsCreate(activePipeline.Workflows); err != nil { log.Error().Err(err).Str("repo", repo.FullName).Msgf("error persisting steps for %s#%d", repo.FullName, activePipeline.Number) return err } publishPipeline(ctx, forge, activePipeline, repo, user) return nil } func publishPipeline(ctx context.Context, forge forge.Forge, pipeline *model.Pipeline, repo *model.Repo, repoUser *model.User) { if err := publishToTopic(ctx, pipeline, repo); err != nil { log.Error().Err(err).Msg("could not push pipeline status change to pubsub provider") } updatePipelineStatus(ctx, forge, pipeline, repo, repoUser) } ================================================ FILE: server/pipeline/status.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import "go.woodpecker-ci.org/woodpecker/v3/server/model" // list of statuses by their priority. Most important is on top. var statusPriorityOrder = []model.StatusValue{ // blocked, declined and created cannot appear in the // same workflow/pipeline at the same time model.StatusDeclined, model.StatusBlocked, model.StatusCreated, // errors have highest priority. model.StatusError, // skipped and killed cannot appear together with running/pending. model.StatusKilled, model.StatusCanceled, // running states model.StatusRunning, model.StatusPending, // finished states model.StatusFailure, model.StatusSuccess, // skipped due to status condition model.StatusSkipped, } var priorityMap map[model.StatusValue]int = buildPriorityMap() func buildPriorityMap() map[model.StatusValue]int { m := map[model.StatusValue]int{} for i, s := range statusPriorityOrder { m[s] = i } return m } func MergeStatusValues(s, t model.StatusValue) model.StatusValue { // both are skipped due to cancellation -> canceled if s == model.StatusCanceled && t == model.StatusCanceled { return model.StatusCanceled } // if only one was skipped -> use killed as the workflow/pipeline was running once already if s == model.StatusCanceled { s = model.StatusKilled } if t == model.StatusCanceled { t = model.StatusKilled } return statusPriorityOrder[min(priorityMap[s], priorityMap[t])] } ================================================ FILE: server/pipeline/status_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestStatusValueMerge(t *testing.T) { tests := []struct { s model.StatusValue t model.StatusValue e model.StatusValue }{ { s: model.StatusSuccess, t: model.StatusSkipped, e: model.StatusSuccess, }, { s: model.StatusSuccess, t: model.StatusSuccess, e: model.StatusSuccess, }, { s: model.StatusFailure, t: model.StatusSuccess, e: model.StatusFailure, }, { s: model.StatusRunning, t: model.StatusSuccess, e: model.StatusRunning, }, { s: model.StatusRunning, t: model.StatusFailure, e: model.StatusRunning, }, { s: model.StatusFailure, t: model.StatusKilled, e: model.StatusKilled, }, { s: model.StatusSkipped, t: model.StatusKilled, e: model.StatusKilled, }, { s: model.StatusSkipped, t: model.StatusSkipped, e: model.StatusSkipped, }, { s: model.StatusSkipped, t: model.StatusCanceled, e: model.StatusKilled, }, { s: model.StatusSuccess, t: model.StatusCanceled, e: model.StatusKilled, }, { s: model.StatusFailure, t: model.StatusCanceled, e: model.StatusKilled, }, } for _, tt := range tests { assert.Equal(t, tt.e, MergeStatusValues(tt.s, tt.t)) assert.Equal(t, tt.e, MergeStatusValues(tt.t, tt.s)) } } ================================================ FILE: server/pipeline/step_builder/metadata.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package step_builder import ( "fmt" "net/url" "strings" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/version" ) // MetadataFromStruct return the metadata from a pipeline will run with. func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, prev *model.Pipeline, workflow *model.Workflow, sysURL string) metadata.Metadata { host := sysURL uri, err := url.Parse(sysURL) if err == nil { host = uri.Host } fForge := metadata.Forge{} if forge != nil { fForge = metadata.Forge{ Type: forge.Name(), URL: forge.URL(), } } fRepo := metadata.Repo{} if repo != nil { fRepo = metadata.Repo{ ID: repo.ID, Name: repo.Name, Owner: repo.Owner, RemoteID: fmt.Sprint(repo.ForgeRemoteID), ForgeURL: repo.ForgeURL, CloneURL: repo.Clone, CloneSSHURL: repo.CloneSSH, Private: repo.IsSCMPrivate, Branch: repo.Branch, Trusted: metadata.TrustedConfiguration{ Network: repo.Trusted.Network, Volumes: repo.Trusted.Volumes, Security: repo.Trusted.Security, }, } if idx := strings.LastIndex(repo.FullName, "/"); idx != -1 { if fRepo.Name == "" && repo.FullName != "" { fRepo.Name = repo.FullName[idx+1:] } if fRepo.Owner == "" && repo.FullName != "" { fRepo.Owner = repo.FullName[:idx] } } } fWorkflow := metadata.Workflow{} if workflow != nil { fWorkflow = metadata.Workflow{ Name: workflow.Name, Number: workflow.PID, Matrix: workflow.Environ, } } return metadata.Metadata{ Repo: fRepo, Curr: metadataPipelineFromModelPipeline(pipeline, true), Prev: metadataPipelineFromModelPipeline(prev, false), Workflow: fWorkflow, Step: metadata.Step{}, Sys: metadata.System{ Name: "woodpecker", URL: sysURL, Host: host, Platform: "", // will be set by pipeline platform option or by agent Version: version.Version, }, Forge: fForge, } } func metadataPipelineFromModelPipeline(pipeline *model.Pipeline, includeParent bool) metadata.Pipeline { if pipeline == nil { return metadata.Pipeline{} } parent := int64(0) if includeParent { parent = pipeline.Parent } return metadata.Pipeline{ Number: pipeline.Number, Parent: parent, Created: pipeline.Created, Started: pipeline.Started, Finished: pipeline.Finished, Status: string(pipeline.Status), Event: metadata.Event(pipeline.Event), EventReason: pipeline.EventReason, ForgeURL: pipeline.ForgeURL, DeployTo: pipeline.DeployTo, DeployTask: pipeline.DeployTask, Commit: metadata.Commit{ Sha: pipeline.Commit, Ref: pipeline.Ref, Refspec: pipeline.Refspec, Branch: pipeline.Branch, Message: pipeline.Message, Author: metadata.Author{ Name: pipeline.Author, Email: pipeline.Email, }, ChangedFiles: pipeline.ChangedFiles, PullRequestLabels: pipeline.PullRequestLabels, PullRequestMilestone: pipeline.PullRequestMilestone, IsPrerelease: pipeline.IsPrerelease, }, Cron: pipeline.Cron, Author: pipeline.Author, Avatar: pipeline.Avatar, } } ================================================ FILE: server/pipeline/step_builder/metadata_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package step_builder import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestMetadataFromStruct(t *testing.T) { forge := mocks.NewMockForge(t) forge.On("Name").Return("gitea") forge.On("URL").Return("https://gitea.com") testCases := []struct { name string forge metadata.ServerForge repo *model.Repo pipeline, prev *model.Pipeline workflow *model.Workflow sysURL string expectedMetadata metadata.Metadata expectedEnviron map[string]string }{ { name: "Test with empty info", expectedMetadata: metadata.Metadata{Sys: metadata.System{Name: "woodpecker"}}, expectedEnviron: map[string]string{ "CI": "woodpecker", "CI_PIPELINE_CREATED": "0", "CI_PIPELINE_FILES": "[]", "CI_PIPELINE_NUMBER": "0", "CI_PIPELINE_PARENT": "0", "CI_PIPELINE_STARTED": "0", "CI_PIPELINE_URL": "/repos/0/pipeline/0", "CI_PREV_PIPELINE_CREATED": "0", "CI_PREV_PIPELINE_FINISHED": "0", "CI_PREV_PIPELINE_NUMBER": "0", "CI_PREV_PIPELINE_PARENT": "0", "CI_PREV_PIPELINE_STARTED": "0", "CI_PREV_PIPELINE_URL": "/repos/0/pipeline/0", "CI_REPO_PRIVATE": "false", "CI_REPO_TRUSTED": "false", "CI_REPO_TRUSTED_NETWORK": "false", "CI_REPO_TRUSTED_SECURITY": "false", "CI_REPO_TRUSTED_VOLUMES": "false", "CI_STEP_NUMBER": "0", "CI_STEP_URL": "/repos/0/pipeline/0", "CI_SYSTEM_NAME": "woodpecker", "CI_WORKFLOW_NUMBER": "0", }, }, { name: "Test with forge", forge: forge, repo: &model.Repo{FullName: "testUser/testRepo", ForgeURL: "https://gitea.com/testUser/testRepo", Clone: "https://gitea.com/testUser/testRepo.git", CloneSSH: "git@gitea.com:testUser/testRepo.git", Branch: "main", IsSCMPrivate: true}, pipeline: &model.Pipeline{Number: 3, ChangedFiles: []string{"test.go", "markdown file.md"}}, prev: &model.Pipeline{Number: 2}, workflow: &model.Workflow{Name: "hello"}, sysURL: "https://example.com", expectedMetadata: metadata.Metadata{ Forge: metadata.Forge{Type: "gitea", URL: "https://gitea.com"}, Sys: metadata.System{Name: "woodpecker", Host: "example.com", URL: "https://example.com"}, Repo: metadata.Repo{Owner: "testUser", Name: "testRepo", ForgeURL: "https://gitea.com/testUser/testRepo", CloneURL: "https://gitea.com/testUser/testRepo.git", CloneSSHURL: "git@gitea.com:testUser/testRepo.git", Branch: "main", Private: true}, Curr: metadata.Pipeline{ Number: 3, Commit: metadata.Commit{ChangedFiles: []string{"test.go", "markdown file.md"}}, }, Prev: metadata.Pipeline{Number: 2}, Workflow: metadata.Workflow{Name: "hello"}, }, expectedEnviron: map[string]string{ "CI": "woodpecker", "CI_FORGE_TYPE": "gitea", "CI_FORGE_URL": "https://gitea.com", "CI_PIPELINE_CREATED": "0", "CI_PIPELINE_FILES": `["test.go","markdown file.md"]`, "CI_PIPELINE_NUMBER": "3", "CI_PIPELINE_PARENT": "0", "CI_PIPELINE_STARTED": "0", "CI_PIPELINE_URL": "https://example.com/repos/0/pipeline/3", "CI_PREV_PIPELINE_CREATED": "0", "CI_PREV_PIPELINE_FINISHED": "0", "CI_PREV_PIPELINE_NUMBER": "2", "CI_PREV_PIPELINE_PARENT": "0", "CI_PREV_PIPELINE_STARTED": "0", "CI_PREV_PIPELINE_URL": "https://example.com/repos/0/pipeline/2", "CI_REPO": "testUser/testRepo", "CI_REPO_CLONE_URL": "https://gitea.com/testUser/testRepo.git", "CI_REPO_CLONE_SSH_URL": "git@gitea.com:testUser/testRepo.git", "CI_REPO_DEFAULT_BRANCH": "main", "CI_REPO_NAME": "testRepo", "CI_REPO_OWNER": "testUser", "CI_REPO_PRIVATE": "true", "CI_REPO_TRUSTED": "false", "CI_REPO_TRUSTED_NETWORK": "false", "CI_REPO_TRUSTED_SECURITY": "false", "CI_REPO_TRUSTED_VOLUMES": "false", "CI_REPO_URL": "https://gitea.com/testUser/testRepo", "CI_STEP_NUMBER": "0", "CI_STEP_URL": "https://example.com/repos/0/pipeline/3", "CI_SYSTEM_HOST": "example.com", "CI_SYSTEM_NAME": "woodpecker", "CI_SYSTEM_URL": "https://example.com", "CI_WORKFLOW_NAME": "hello", "CI_WORKFLOW_NUMBER": "0", }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { result := MetadataFromStruct(testCase.forge, testCase.repo, testCase.pipeline, testCase.prev, testCase.workflow, testCase.sysURL) assert.EqualValues(t, testCase.expectedMetadata, result) assert.EqualValues(t, testCase.expectedEnviron, result.Environ()) }) } } ================================================ FILE: server/pipeline/step_builder/step_builder.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package step_builder import ( "fmt" "maps" "path/filepath" "slices" "strconv" "strings" "github.com/oklog/ulid/v2" "github.com/rs/zerolog/log" "go.uber.org/multierr" "go.woodpecker-ci.org/woodpecker/v3/pipeline" backend_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/backend/types" pipeline_errors "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/linter" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/matrix" yaml_types "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/types" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // StepBuilder Takes the hook data and the yaml and returns the internal data model. type StepBuilder struct { Repo *model.Repo // TODO: get rid of server dependency Curr *model.Pipeline // TODO: get rid of server dependency Prev *model.Pipeline // TODO: get rid of server dependency Host string Yamls []*forge_types.FileMeta Envs map[string]string Forge metadata.ServerForge DefaultLabels map[string]string RepoTrusted *metadata.TrustedConfiguration TrustedClonePlugins []string PrivilegedPlugins []string CompilerOptions []compiler.Option } type Item struct { Workflow *model.Workflow // TODO: get rid of server dependency Labels map[string]string DependsOn []string RunsOn []string Config *backend_types.Config } func (b *StepBuilder) Build() (items []*Item, errorsAndWarnings error) { b.Yamls = forge_types.SortByName(b.Yamls) pidSequence := 1 for _, y := range b.Yamls { // matrix axes axes, err := matrix.ParseString(string(y.Data)) if err != nil { return nil, err } if len(axes) == 0 { axes = append(axes, matrix.Axis{}) } for i, axis := range axes { workflow := &model.Workflow{ PID: pidSequence, State: model.StatusPending, Environ: axis, Name: SanitizePath(y.Name), } if len(axes) > 1 { workflow.AxisID = i + 1 } item, err := b.genItemForWorkflow(workflow, axis, string(y.Data)) if err != nil && pipeline_errors.HasBlockingErrors(err) { return nil, err } else if err != nil { errorsAndWarnings = multierr.Append(errorsAndWarnings, err) } if item == nil { continue } items = append(items, item) pidSequence++ } // TODO: add summary workflow that send status back based on workflows generated by matrix function // depend on https://github.com/woodpecker-ci/woodpecker/issues/778 } items = filterItemsWithMissingDependencies(items) return items, errorsAndWarnings } func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.Axis, data string) (item *Item, errorsAndWarnings error) { workflowMetadata := MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Prev, workflow, b.Host) environ := b.environmentVariables(workflowMetadata, axis) // add global environment variables for substituting for k, v := range b.Envs { if _, exists := environ[k]; exists { // don't override existing values continue } environ[k] = v } // substitute vars substituted, err := metadata.EnvVarSubst(data, environ) if err != nil { return nil, multierr.Append(errorsAndWarnings, err) } // parse yaml pipeline parsed, err := yaml.ParseString(substituted) if err != nil { return nil, &pipeline_errors.PipelineError{Message: err.Error(), Type: pipeline_errors.PipelineErrorTypeCompiler} } // lint pipeline errorsAndWarnings = multierr.Append(errorsAndWarnings, linter.New( linter.WithTrusted(linter.TrustedConfiguration{ Network: b.Repo.Trusted.Network, Volumes: b.Repo.Trusted.Volumes, Security: b.Repo.Trusted.Security, }), linter.PrivilegedPlugins(b.PrivilegedPlugins), linter.WithTrustedClonePlugins(b.TrustedClonePlugins), ).Lint([]*linter.WorkflowConfig{{ Workflow: parsed, File: workflow.Name, RawConfig: data, }})) if pipeline_errors.HasBlockingErrors(errorsAndWarnings) { return nil, errorsAndWarnings } // checking if filtered. if match, err := parsed.When.Match(workflowMetadata, true, environ); !match && err == nil { log.Debug().Str("pipeline", workflow.Name).Msg( "marked as skipped, does not match metadata", ) return nil, nil } else if err != nil { log.Debug().Str("pipeline", workflow.Name).Msg( "pipeline config could not be parsed", ) return nil, multierr.Append(errorsAndWarnings, err) } ir, err := b.toInternalRepresentation(parsed, environ, workflowMetadata, workflow.ID) if err != nil { return nil, multierr.Append(errorsAndWarnings, err) } if len(ir.Stages) == 0 { return nil, nil } item = &Item{ Workflow: workflow, Config: ir, Labels: parsed.Labels, DependsOn: parsed.DependsOn, RunsOn: parsed.RunsOn, //nolint:staticcheck // TODO: remove in next major. } if len(item.Labels) == 0 { item.Labels = make(map[string]string, len(b.DefaultLabels)) // Set default labels if no labels are defined in the pipeline maps.Copy(item.Labels, b.DefaultLabels) } if !slices.Contains(item.RunsOn, "failure") && parsed.When.IncludesStatusFailure(workflowMetadata, true, environ) { item.RunsOn = append(item.RunsOn, "failure") } if !slices.Contains(item.RunsOn, "success") && parsed.When.IncludesStatusFailure(workflowMetadata, true, environ) { item.RunsOn = append(item.RunsOn, "success") } // "woodpecker-ci.org" namespace is reserved for internal use for key := range item.Labels { if strings.HasPrefix(key, pipeline.InternalLabelPrefix) { log.Debug().Str("forge", b.Forge.Name()).Str("repo", b.Repo.FullName).Str("label", key).Msg("dropped pipeline label with reserved prefix woodpecker-ci.org") delete(item.Labels, key) } } // Add Woodpecker managed labels to the pipeline item.Labels[pipeline.LabelForgeRemoteID] = b.Forge.Name() item.Labels[pipeline.LabelRepoForgeID] = string(b.Repo.ForgeRemoteID) item.Labels[pipeline.LabelRepoID] = strconv.FormatInt(b.Repo.ID, 10) item.Labels[pipeline.LabelRepoName] = b.Repo.Name item.Labels[pipeline.LabelRepoFullName] = b.Repo.FullName item.Labels[pipeline.LabelBranch] = b.Repo.Branch item.Labels[pipeline.LabelOrgID] = strconv.FormatInt(b.Repo.OrgID, 10) for stageI := range item.Config.Stages { for stepI := range item.Config.Stages[stageI].Steps { item.Config.Stages[stageI].Steps[stepI].WorkflowLabels = item.Labels item.Config.Stages[stageI].Steps[stepI].OrgID = b.Repo.OrgID } } return item, errorsAndWarnings } func filterItemsWithMissingDependencies(items []*Item) []*Item { itemsToRemove := make([]*Item, 0) for _, item := range items { for _, dep := range item.DependsOn { if !containsItemWithName(dep, items) { itemsToRemove = append(itemsToRemove, item) } } } if len(itemsToRemove) > 0 { filtered := make([]*Item, 0) for _, item := range items { if !containsItemWithName(item.Workflow.Name, itemsToRemove) { filtered = append(filtered, item) } } // Recursive to handle transitive deps return filterItemsWithMissingDependencies(filtered) } return items } func containsItemWithName(name string, items []*Item) bool { for _, item := range items { if name == item.Workflow.Name { return true } } return false } func (b *StepBuilder) environmentVariables(metadata metadata.Metadata, axis matrix.Axis) map[string]string { environ := metadata.Environ() maps.Copy(environ, axis) return environ } func (b *StepBuilder) toInternalRepresentation(parsed *yaml_types.Workflow, environ map[string]string, metadata metadata.Metadata, workflowID int64) (*backend_types.Config, error) { options := []compiler.Option{} options = append(options, compiler.WithEnviron(environ), compiler.WithEnviron(b.Envs), compiler.WithEscalated(b.PrivilegedPlugins...), compiler.WithTrustedClonePlugins(b.TrustedClonePlugins), compiler.WithPrefix( fmt.Sprintf( "wp_%s_%d", strings.ToLower(ulid.Make().String()), workflowID, ), ), compiler.WithMetadata(metadata), compiler.WithTrustedSecurity(b.RepoTrusted.Security), ) // by adding the passed in options last, we allow them // to override any of the default options set above options = append(options, b.CompilerOptions...) return compiler.New(options...).Compile(parsed) } func SanitizePath(path string) string { path = filepath.Base(path) path = strings.TrimSuffix(path, ".yml") path = strings.TrimSuffix(path, ".yaml") path = strings.TrimPrefix(path, ".") return path } ================================================ FILE: server/pipeline/step_builder/step_builder_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package step_builder import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/pipeline/errors" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v3/pipeline/frontend/yaml/compiler" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestGlobalEnvsubst(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), Envs: map[string]string{ "KEY_K": "VALUE_V", "IMAGE": "scratch", }, RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{ Message: "aaa", Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push steps: - name: build image: ${IMAGE} settings: yyy: ${CI_COMMIT_MESSAGE} `)}, }, } _, err := b.Build() assert.NoError(t, err) } func TestMissingGlobalEnvsubst(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), Envs: map[string]string{ "KEY_K": "VALUE_V", "NO_IMAGE": "scratch", }, RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{ Message: "aaa", Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push steps: - name: build image: ${IMAGE} settings: yyy: ${CI_COMMIT_MESSAGE} `)}, }, } _, err := b.Build() assert.Error(t, err, "test erroneously succeeded") } func TestMultilineEnvsubst(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{ Message: `aaa bbb`, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push steps: - name: xxx image: scratch settings: yyy: ${CI_COMMIT_MESSAGE} `)}, {Data: []byte(` when: event: push steps: - name: build image: scratch settings: yyy: ${CI_COMMIT_MESSAGE} `)}, }, } _, err := b.Build() assert.NoError(t, err) } func TestMultiPipeline(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), Repo: &model.Repo{}, RepoTrusted: &metadata.TrustedConfiguration{}, Curr: &model.Pipeline{ Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push steps: - name: xxx image: scratch `)}, {Data: []byte(` when: event: push steps: - name: build image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 2, "Should have generated 2 items") } func TestDependsOn(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), Repo: &model.Repo{}, RepoTrusted: &metadata.TrustedConfiguration{}, Curr: &model.Pipeline{ Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Name: "lint", Data: []byte(` when: event: push steps: - name: build image: scratch `)}, {Name: "test", Data: []byte(` when: event: push steps: - name: build image: scratch `)}, {Name: "deploy", Data: []byte(` when: event: push steps: - name: deploy image: scratch depends_on: - lint - test `)}, {Name: "missing dependencies", Data: []byte(` when: event: push steps: - name: deploy image: scratch depends_on: - missing `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 3, "Should have generated 3 items") assert.Len(t, items[0].DependsOn, 2, "Should have 2 dependencies") assert.Equal(t, "test", items[0].DependsOn[1], "Should depend on test") } func TestRunsOn(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{ Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push status: [ success, failure ] steps: - name: deploy image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items[0].RunsOn, 2, "Should run on success and failure") assert.ElementsMatchf(t, []string{"success", "failure"}, items[0].RunsOn, "Should run on failure") } func TestPipelineName(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{Config: ".woodpecker"}, Curr: &model.Pipeline{ Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Name: ".woodpecker/lint.yml", Data: []byte(` when: event: push steps: - name: build image: scratch `)}, {Name: ".woodpecker/.test.yml", Data: []byte(` when: event: push steps: - name: build image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) pipelineNames := []string{items[0].Workflow.Name, items[1].Workflow.Name} assert.True(t, containsItemWithName("lint", items) && containsItemWithName("test", items), "Pipeline name should be 'lint' and 'test' but are '%v'", pipelineNames) } func TestBranchFilter(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{ Branch: "dev", Event: model.EventPush, }, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push branch: main steps: - name: xxx image: scratch `)}, {Data: []byte(` when: event: push steps: - name: build image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 1, "Should have generated 1 pipeline") assert.Equal(t, model.StatusPending, items[0].Workflow.State, "Should run on dev branch") } func TestRootWhenFilter(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{Event: "tag"}, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: - tag steps: - name: xxx image: scratch `)}, {Data: []byte(` when: event: - push steps: - name: xxx image: scratch `)}, {Data: []byte(` steps: - name: build image: scratch `)}, }, } items, err := b.Build() assert.False(t, errors.HasBlockingErrors(err)) assert.Len(t, items, 2, "Should have generated 2 items") } func TestZeroSteps(t *testing.T) { t.Parallel() pipeline := &model.Pipeline{ Branch: "dev", Event: model.EventPush, } b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: pipeline, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push skip_clone: true steps: - name: build when: branch: notdev image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Empty(t, items, "Should not generate a pipeline item if there are no steps") } func TestZeroStepsAsMultiPipelineTransitiveDeps(t *testing.T) { t.Parallel() pipeline := &model.Pipeline{ Branch: "dev", Event: model.EventPush, } b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: pipeline, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Name: "zerostep", Data: []byte(` when: event: push skip_clone: true steps: - name: build when: branch: notdev image: scratch `)}, {Name: "justastep", Data: []byte(` when: event: push steps: - name: build image: scratch `)}, {Name: "shouldbefiltered", Data: []byte(` when: event: push steps: - name: build image: scratch depends_on: [ zerostep ] `)}, {Name: "shouldbefilteredtoo", Data: []byte(` when: event: push steps: - name: build image: scratch depends_on: [ shouldbefiltered ] `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 1, "Zerostep and the step that depends on it, and the one depending on it should not generate a pipeline item") assert.Equal(t, "justastep", items[0].Workflow.Name, "justastep should have been generated") } func TestSanitizePath(t *testing.T) { t.Parallel() testTable := []struct { path string sanitizedPath string }{ { path: ".woodpecker/test.yml", sanitizedPath: "test", }, { path: ".woodpecker.yml", sanitizedPath: "woodpecker", }, { path: "folder/sub-folder/test.yml", sanitizedPath: "test", }, { path: ".woodpecker/test.yaml", sanitizedPath: "test", }, { path: ".woodpecker.yaml", sanitizedPath: "woodpecker", }, { path: "folder/sub-folder/test.yaml", sanitizedPath: "test", }, } for _, test := range testTable { assert.Equal(t, test.sanitizedPath, SanitizePath(test.path), "Path hasn't been sanitized correctly") } } func TestMatrix(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{Event: model.EventPush}, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push matrix: GO_VERSION: - 1.14 - 1.15 steps: - name: build image: golang:${GO_VERSION} commands: - go build `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 2) // Check AxisID and Environ assert.Equal(t, 1, items[0].Workflow.AxisID) assert.Equal(t, "1.14", items[0].Workflow.Environ["GO_VERSION"]) assert.Equal(t, 2, items[1].Workflow.AxisID) assert.Equal(t, "1.15", items[1].Workflow.Environ["GO_VERSION"]) } func TestMissingWorkflowDeps(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{Event: model.EventPush}, Prev: &model.Pipeline{}, Host: "", Yamls: []*forge_types.FileMeta{ { Name: "workflow-with-missing-deps", Data: []byte(` when: event: push steps: - name: build image: scratch depends_on: - non-existing `), }, }, } items, err := b.Build() assert.NoError(t, err) assert.Empty(t, items, "Workflows with missing dependencies should be filtered out") } func TestInvalidYAML(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: nil, RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{Event: model.EventPush}, Prev: &model.Pipeline{}, Yamls: []*forge_types.FileMeta{ {Name: "broken-yaml", Data: []byte(` when: event: push steps: - name: build image: scratch invalid yaml indentation `)}, }, } _, err := b.Build() assert.ErrorContains(t, err, "found a tab character that violates indentation") } func TestEnvVarPrecedence(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), Envs: map[string]string{ "CUSTOM_VAR": "global-value", "CI_REPO_NAME": "should-not-override", "ANOTHER_CUSTOM": "global-value-2", }, RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{Name: "actual-repo"}, Curr: &model.Pipeline{ Event: model.EventPush, Message: "test", }, Prev: &model.Pipeline{}, Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push steps: - name: test-env image: scratch environment: CUSTOM_VAR: ${CUSTOM_VAR} REPO_NAME: ${CI_REPO_NAME} ANOTHER: ${ANOTHER_CUSTOM} `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 1) // Verify CI_REPO_NAME wasn't overridden by global env assert.Equal(t, "actual-repo", items[0].Config.Stages[0].Steps[0].Environment["CI_REPO_NAME"]) } func TestLabelMerging(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{Name: "test-repo"}, Curr: &model.Pipeline{Event: model.EventPush}, Prev: &model.Pipeline{}, DefaultLabels: map[string]string{ "default-label": "default-value", "override-me": "default", }, Yamls: []*forge_types.FileMeta{ {Data: []byte(` when: event: push labels: override-me: "custom-value" workflow-label: "workflow-value" steps: - name: build image: scratch `)}, {Data: []byte(` when: event: push steps: - name: build image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 2) assert.Equal(t, "custom-value", items[0].Labels["override-me"], "Workflow label should override default") assert.Equal(t, "workflow-value", items[0].Labels["workflow-label"], "Workflow-specific label should be present") assert.Equal(t, "default-value", items[1].Labels["default-label"], "Default label should be present") } func TestCompilerOptions(t *testing.T) { t.Parallel() b := StepBuilder{ Forge: getMockForge(t), RepoTrusted: &metadata.TrustedConfiguration{}, Repo: &model.Repo{}, Curr: &model.Pipeline{Event: model.EventPush}, Prev: &model.Pipeline{}, CompilerOptions: []compiler.Option{ compiler.WithEnviron(map[string]string{ "KEY": "VALUE", }), }, Yamls: []*forge_types.FileMeta{ {Data: []byte(` skip_clone: true when: event: push steps: - name: build image: scratch `)}, }, } items, err := b.Build() assert.NoError(t, err) assert.Len(t, items, 1) assert.Len(t, items[0].Config.Stages, 1, "Should have 1 stage") assert.Len(t, items[0].Config.Stages[0].Steps, 1, "Should have 1 step in first stage") assert.Equal(t, "VALUE", items[0].Config.Stages[0].Steps[0].Environment["KEY"], "Environment variable should be set") } func getMockForge(t *testing.T) forge.Forge { forge := mocks.NewMockForge(t) forge.On("Name").Return("mock") forge.On("URL").Return("https://codeberg.org") return forge } ================================================ FILE: server/pipeline/step_status.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2019 mhmxs // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "fmt" "time" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func CalcStepStatus(step model.Step, state rpc.StepState) (_ *model.Step, cancelPipelineFromStep bool, _ error) { log.Debug().Str("StepUUID", step.UUID).Msgf("Update step %#v state %#v", step, state) switch step.State { case model.StatusPending: // Handle skip before anything else — skipped steps never started, // so we must not set Started or transition through Running. if state.Skipped { step.State = model.StatusSkipped if state.Finished != 0 { step.Finished = state.Finished } return &step, false, nil } // Transition from pending to running when started if state.Finished == 0 { step.State = model.StatusRunning } step.Started = state.Started if step.Started == 0 { step.Started = time.Now().Unix() } // Handle direct transition to finished if step setup error happened if state.Exited || state.Error != "" { step.Finished = state.Finished if step.Finished == 0 { step.Finished = time.Now().Unix() } step.ExitCode = state.ExitCode step.Error = state.Error if state.ExitCode == 0 && state.Error == "" { step.State = model.StatusSuccess } else { step.State = model.StatusFailure if step.Failure == model.FailureCancel { cancelPipelineFromStep = true } } } case model.StatusRunning: // Already running, check if it finished if state.Exited || state.Error != "" { step.Finished = state.Finished if step.Finished == 0 { step.Finished = time.Now().Unix() } step.ExitCode = state.ExitCode step.Error = state.Error if state.ExitCode == 0 && state.Error == "" { step.State = model.StatusSuccess } else { step.State = model.StatusFailure if step.Failure == model.FailureCancel { cancelPipelineFromStep = true } } } default: return nil, false, fmt.Errorf("step has state %s and does not expect rpc state updates", step.State) } // Handle cancellation across both cases if state.Canceled && step.State != model.StatusKilled { step.State = model.StatusKilled if step.Finished == 0 { step.Finished = time.Now().Unix() } } return &step, cancelPipelineFromStep, nil } // UpdateStepStatus updates step status based on agent reports via RPC. func UpdateStepStatus(ctx context.Context, store store.Store, step *model.Step, state rpc.StepState) error { log.Debug().Str("StepUUID", step.UUID).Msgf("Update step %#v state %#v", *step, state) updatedStep, shouldCancelPipelineFromStep, err := CalcStepStatus(*step, state) if err != nil { return err } *step = *updatedStep // update step for external callers if shouldCancelPipelineFromStep { if err := cancelPipelineFromStep(ctx, store, step); err != nil { return err } } return store.StepUpdate(step) } func cancelPipelineFromStep(ctx context.Context, store store.Store, step *model.Step) error { pipeline, err := store.GetPipeline(step.PipelineID) if err != nil { return err } repo, err := store.GetRepo(pipeline.RepoID) if err != nil { return err } repoUser, err := store.GetUser(repo.UserID) if err != nil { return err } _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { return err } return Cancel(ctx, _forge, store, repo, repoUser, pipeline, &model.CancelInfo{ CanceledByStep: step.Name, }) } func UpdateStepToStatusSkipped(store store.Store, step model.Step, finished int64, status model.StatusValue) (*model.Step, error) { step.State = status if step.Started != 0 { step.State = model.StatusSuccess // for daemons that are killed step.Finished = finished } return &step, store.StepUpdate(&step) } ================================================ FILE: server/pipeline/step_status_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func mockStoreStep(t *testing.T) store.Store { s := mocks.NewMockStore(t) s.On("StepUpdate", mock.Anything).Return(nil) return s } func TestUpdateStepStatus(t *testing.T) { t.Parallel() t.Run("Pending", func(t *testing.T) { t.Parallel() t.Run("TransitionToRunning", func(t *testing.T) { t.Parallel() t.Run("WithStartTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Finished: 0} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, step.State) assert.Equal(t, int64(42), step.Started) assert.Equal(t, int64(0), step.Finished) }) t.Run("WithoutStartTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 0, Finished: 0} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, step.State) assert.Greater(t, step.Started, int64(0)) }) }) t.Run("DirectToSuccess", func(t *testing.T) { t.Parallel() t.Run("WithFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Exited: true, Finished: 100, ExitCode: 0, Error: ""} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) assert.Equal(t, int64(42), step.Started) assert.Equal(t, int64(100), step.Finished) }) t.Run("WithoutFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Exited: true, Finished: 0, ExitCode: 0, Error: ""} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) assert.Greater(t, step.Finished, int64(0)) }) }) t.Run("DirectToFailure", func(t *testing.T) { t.Parallel() t.Run("WithExitCode", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Started: 42, Exited: true, Finished: 34, ExitCode: 1, Error: "an error"} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, step.State) assert.Equal(t, 1, step.ExitCode) assert.Equal(t, "an error", step.Error) }) }) }) t.Run("Running", func(t *testing.T) { t.Parallel() t.Run("ToSuccess", func(t *testing.T) { t.Parallel() t.Run("WithFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 100, ExitCode: 0, Error: ""} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) assert.Equal(t, int64(100), step.Finished) }) t.Run("WithoutFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 0, ExitCode: 0, Error: ""} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) assert.Greater(t, step.Finished, int64(0)) }) }) t.Run("ToFailure", func(t *testing.T) { t.Parallel() t.Run("WithExitCode137", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 34, ExitCode: pipeline.ExitCodeKilled, Error: "an error"} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, step.State) assert.Equal(t, int64(34), step.Finished) assert.Equal(t, pipeline.ExitCodeKilled, step.ExitCode) }) t.Run("WithError", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: true, Finished: 34, ExitCode: 0, Error: "an error"} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, step.State) assert.Equal(t, "an error", step.Error) }) }) t.Run("StillRunning", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Exited: false, Finished: 0} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, step.State) assert.Equal(t, int64(0), step.Finished) }) }) t.Run("Canceled", func(t *testing.T) { t.Parallel() t.Run("WithoutFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Canceled: true} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusKilled, step.State) assert.Greater(t, step.Finished, int64(0)) }) t.Run("WithExitedAndFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning, Started: 42} state := rpc.StepState{Canceled: true, Exited: true, Finished: 100, ExitCode: 1, Error: "canceled"} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusKilled, step.State) assert.Equal(t, int64(100), step.Finished) assert.Equal(t, 1, step.ExitCode) assert.Equal(t, "canceled", step.Error) }) }) t.Run("Skipped", func(t *testing.T) { t.Parallel() // This mirrors exactly what the agent sends when executor.go detects // OnSuccess=false or OnFailure=false — only Skipped is set, everything // else is zero/false (no Started, no Finished, not Exited). t.Run("PendingToSkipped_AgentPayload", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} // Exact payload from: traceStep(&backend.State{Skipped: true}, nil, step) // Started=0, Finished=0, Exited=false, Skipped=true state := rpc.StepState{ Skipped: true, Exited: false, Finished: 0, Started: 0, } err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) // Must be Skipped, NOT Running (the bug: Finished==0 triggers StatusRunning first) assert.Equal(t, model.StatusSkipped, step.State) // Started must NOT be set — skipped steps never ran assert.Equal(t, int64(0), step.Started) // Finished must NOT be set — skipped steps never ran assert.Equal(t, int64(0), step.Finished) }) t.Run("PendingToSkipped", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Skipped: true} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSkipped, step.State) }) t.Run("PendingToSkippedWithFinishTime", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Skipped: true, Exited: true, Finished: 50} err := UpdateStepStatus(t.Context(), mockStoreStep(t), step, state) assert.NoError(t, err) assert.Equal(t, model.StatusSkipped, step.State) assert.Equal(t, int64(50), step.Finished) }) }) t.Run("TerminalState", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusKilled, Started: 42, Finished: 64} state := rpc.StepState{Exited: false} err := UpdateStepStatus(t.Context(), mocks.NewMockStore(t), step, state) assert.Error(t, err) assert.Contains(t, err.Error(), "does not expect rpc state updates") assert.Equal(t, model.StatusKilled, step.State) }) } func TestUpdateStepToStatusSkipped(t *testing.T) { t.Parallel() t.Run("NotStarted", func(t *testing.T) { t.Parallel() step, err := UpdateStepToStatusSkipped(mockStoreStep(t), model.Step{}, int64(1), model.StatusSkipped) assert.NoError(t, err) assert.Equal(t, model.StatusSkipped, step.State) assert.Equal(t, int64(0), step.Finished) }) t.Run("AlreadyStarted", func(t *testing.T) { t.Parallel() step, err := UpdateStepToStatusSkipped(mockStoreStep(t), model.Step{Started: 42}, int64(100), model.StatusSkipped) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, step.State) assert.Equal(t, int64(100), step.Finished) }) } ================================================ FILE: server/pipeline/topic.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "context" "encoding/json" "fmt" "github.com/oklog/ulid/v2" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" ) // publishToTopic publishes message to UI clients. func publishToTopic(c context.Context, pipeline *model.Pipeline, repo *model.Repo) (err error) { message := pubsub.Message{ID: ulid.Make().String()} message.Data, err = json.Marshal(model.Event{ Repo: *repo, Pipeline: *pipeline, }) if err != nil { return fmt.Errorf("can't marshal JSON: %w", err) } subTopics := make(map[string]struct{}) // if repo is public, push to public topic if !repo.IsSCMPrivate { subTopics[pubsub.PublicTopic] = struct{}{} } // publish to repo specific topic subTopics[pubsub.GetRepoTopic(repo)] = struct{}{} return server.Config.Services.Scheduler.Publish(c, subTopics, message) } ================================================ FILE: server/pipeline/workflow_status.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // WorkflowStatus determine workflow status based on corresponding step list. func WorkflowStatus(steps []*model.Step) model.StatusValue { status := model.StatusSuccess for _, p := range steps { if p.Failure == model.FailureFail || !p.Failing() { status = MergeStatusValues(status, p.State) } } return status } func UpdateWorkflowStatusToRunning(store store.Store, workflow model.Workflow, state rpc.WorkflowState) (*model.Workflow, error) { workflow.Started = state.Started workflow.State = model.StatusRunning return &workflow, store.WorkflowUpdate(&workflow) } func UpdateWorkflowToStatusSkipped(store store.Store, workflow model.Workflow) (*model.Workflow, error) { workflow.State = model.StatusSkipped return &workflow, store.WorkflowUpdate(&workflow) } func UpdateWorkflowStatusToDone(store store.Store, workflow model.Workflow, state rpc.WorkflowState) (*model.Workflow, error) { workflow.Finished = state.Finished workflow.Error = state.Error if state.Started == 0 { workflow.State = model.StatusSkipped } else { workflow.State = WorkflowStatus(workflow.Children) } if workflow.Error != "" { workflow.State = model.StatusFailure } if state.Canceled { workflow.State = model.StatusKilled } return &workflow, store.WorkflowUpdate(&workflow) } ================================================ FILE: server/pipeline/workflow_status_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pipeline import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestWorkflowStatus(t *testing.T) { tests := []struct { s []*model.Step e model.StatusValue }{ { s: []*model.Step{ { State: model.StatusFailure, Failure: model.FailureIgnore, }, { State: model.StatusSuccess, Failure: model.FailureFail, }, }, e: model.StatusSuccess, }, { s: []*model.Step{ { State: model.StatusSuccess, Failure: model.FailureFail, }, { State: model.StatusSuccess, Failure: model.FailureIgnore, }, }, e: model.StatusSuccess, }, { s: []*model.Step{ { State: model.StatusFailure, Failure: model.FailureFail, }, { State: model.StatusSuccess, Failure: model.FailureFail, }, }, e: model.StatusFailure, }, { s: []*model.Step{ { State: model.StatusSuccess, Failure: model.FailureFail, }, { State: model.StatusPending, Failure: model.FailureFail, }, }, e: model.StatusPending, }, { s: []*model.Step{ { State: model.StatusSuccess, Failure: model.FailureFail, }, { State: model.StatusPending, Failure: model.FailureIgnore, }, }, e: model.StatusPending, }, { s: []*model.Step{ { State: model.StatusSuccess, Failure: model.FailureIgnore, }, { State: model.StatusPending, Failure: model.FailureFail, }, }, e: model.StatusPending, }, { s: []*model.Step{ { State: model.StatusSuccess, Failure: model.FailureIgnore, }, { State: model.StatusPending, Failure: model.FailureIgnore, }, }, e: model.StatusPending, }, { s: []*model.Step{ { State: model.StatusRunning, Failure: model.FailureFail, }, { State: model.StatusPending, Failure: model.FailureFail, }, }, e: model.StatusRunning, }, { s: []*model.Step{ { State: model.StatusRunning, Failure: model.FailureIgnore, }, { State: model.StatusPending, Failure: model.FailureIgnore, }, }, e: model.StatusRunning, }, { s: []*model.Step{ { State: model.StatusRunning, Failure: model.FailureIgnore, }, { State: model.StatusPending, Failure: model.FailureFail, }, }, e: model.StatusRunning, }, { s: []*model.Step{ { State: model.StatusRunning, Failure: model.FailureFail, }, { State: model.StatusPending, Failure: model.FailureIgnore, }, }, e: model.StatusRunning, }, } for _, tt := range tests { assert.Equal(t, tt.e, WorkflowStatus(tt.s)) } } func TestUpdateWorkflowStatusToRunning(t *testing.T) { t.Run("should update workflow to running status", func(t *testing.T) { workflow := model.Workflow{ ID: 1, State: model.StatusPending, } state := rpc.WorkflowState{ Started: 1234567890, } mockStore := store_mocks.NewMockStore(t) mockStore.On("WorkflowUpdate", mock.MatchedBy(func(w *model.Workflow) bool { return w.ID == 1 && w.State == model.StatusRunning && w.Started == 1234567890 })).Return(nil) result, err := UpdateWorkflowStatusToRunning(mockStore, workflow, state) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, result.State) assert.Equal(t, int64(1234567890), result.Started) mockStore.AssertCalled(t, "WorkflowUpdate", mock.Anything) }) } func TestUpdateWorkflowToStatusSkipped(t *testing.T) { t.Run("should update workflow to skipped status", func(t *testing.T) { workflow := model.Workflow{ ID: 2, State: model.StatusPending, } mockStore := store_mocks.NewMockStore(t) mockStore.On("WorkflowUpdate", mock.MatchedBy(func(w *model.Workflow) bool { return w.ID == 2 && w.State == model.StatusSkipped })).Return(nil) result, err := UpdateWorkflowToStatusSkipped(mockStore, workflow) assert.NoError(t, err) assert.Equal(t, model.StatusSkipped, result.State) mockStore.AssertCalled(t, "WorkflowUpdate", mock.Anything) }) } func TestUpdateWorkflowStatusToDone(t *testing.T) { t.Run("should mark as skipped when not started", func(t *testing.T) { workflow := model.Workflow{ ID: 3, State: model.StatusRunning, Children: []*model.Step{}, } state := rpc.WorkflowState{ Started: 0, // Not started Finished: 1234567900, Error: "", } mockStore := store_mocks.NewMockStore(t) mockStore.On("WorkflowUpdate", mock.MatchedBy(func(w *model.Workflow) bool { return w.State == model.StatusSkipped && w.Finished == 1234567900 })).Return(nil) result, err := UpdateWorkflowStatusToDone(mockStore, workflow, state) assert.NoError(t, err) assert.Equal(t, model.StatusSkipped, result.State) assert.Equal(t, int64(1234567900), result.Finished) }) t.Run("should mark as failure when error exists", func(t *testing.T) { workflow := model.Workflow{ ID: 5, State: model.StatusRunning, Children: []*model.Step{}, } state := rpc.WorkflowState{ Started: 1234567800, Finished: 1234567900, Error: "some error occurred", } mockStore := store_mocks.NewMockStore(t) mockStore.On("WorkflowUpdate", mock.MatchedBy(func(w *model.Workflow) bool { return w.State == model.StatusFailure })).Return(nil) result, err := UpdateWorkflowStatusToDone(mockStore, workflow, state) assert.NoError(t, err) assert.Equal(t, model.StatusFailure, result.State) assert.Equal(t, "some error occurred", result.Error) }) t.Run("should mark as success when all children are successful", func(t *testing.T) { successStep := &model.Step{ ID: 1, State: model.StatusSuccess, } workflow := model.Workflow{ ID: 6, State: model.StatusRunning, Children: []*model.Step{successStep}, } state := rpc.WorkflowState{ Started: 1234567800, Finished: 1234567900, Error: "", } mockStore := store_mocks.NewMockStore(t) mockStore.On("WorkflowUpdate", mock.Anything).Return(nil) result, err := UpdateWorkflowStatusToDone(mockStore, workflow, state) assert.NoError(t, err) assert.Equal(t, model.StatusSuccess, result.State) assert.Equal(t, int64(1234567900), result.Finished) }) } ================================================ FILE: server/pubsub/memory/pub.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package memory import ( "context" "fmt" "slices" "sync" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" ) type publisher struct { sync.RWMutex subs map[*pubsub.Receiver][]string } // New creates an in-memory publisher. func New() pubsub.PubSub { return &publisher{ subs: make(map[*pubsub.Receiver][]string), } } func (p *publisher) Publish(_ context.Context, topics pubsub.Topics, message pubsub.Message) error { if len(topics) == 0 { return fmt.Errorf("%w: specify at least one", pubsub.ErrNoTopic) } p.RLock() defer p.RUnlock() for s, tl := range p.subs { // callback is from outside so just make sure it still exists if s == nil || *s == nil { log.Error().Msg("found nil callback func in subscribers!") continue } for t := range topics { if slices.Contains(tl, t) { go (*s)(message) break } } } return nil } func (p *publisher) Subscribe(c context.Context, topics pubsub.Topics, receiver pubsub.Receiver) error { if len(topics) == 0 { return fmt.Errorf("%w: subscribe to at least one", pubsub.ErrNoTopic) } var tl []string for k := range topics { tl = append(tl, k) } defer func() { p.Lock() delete(p.subs, &receiver) p.Unlock() }() p.Lock() p.subs[&receiver] = tl p.Unlock() <-c.Done() return nil } ================================================ FILE: server/pubsub/memory/pub_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package memory import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" ) func TestPubsub(t *testing.T) { var ( wg sync.WaitGroup testTopic = map[string]struct{}{"test": {}} testMessage = pubsub.Message{ Data: []byte("test"), } ) ctx, cancel := context.WithCancelCause( t.Context(), ) broker := New() assert.Error(t, broker.Subscribe(ctx, nil, func(pubsub.Message) {})) go func() { assert.NoError(t, broker.Subscribe(ctx, testTopic, func(message pubsub.Message) { assert.Equal(t, testMessage, message); wg.Done() })) }() go func() { assert.NoError(t, broker.Subscribe(ctx, testTopic, func(pubsub.Message) { wg.Done() })) }() <-time.After(500 * time.Millisecond) wg.Add(2) go func() { assert.NoError(t, broker.Publish(ctx, testTopic, testMessage)) }() wg.Wait() cancel(nil) } func TestPubsubConcurrentCancel(t *testing.T) { testTopic := map[string]struct{}{"test": {}} broker := New() for range 100 { ctx, cancel := context.WithCancelCause(t.Context()) ch := make(chan []byte) // Unbuffered to force blocking sends var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() _ = broker.Subscribe(ctx, testTopic, func(m pubsub.Message) { select { case <-ctx.Done(): case ch <- m.Data: } }) }() // Start publishing many messages to increase chance of blocking send var pubWg sync.WaitGroup for range 100 { pubWg.Add(1) go func() { defer pubWg.Done() _ = broker.Publish(ctx, testTopic, pubsub.Message{Data: []byte("x")}) }() } // Cancel while publishes are in flight to race with pending sends cancel(nil) pubWg.Wait() wg.Wait() } } ================================================ FILE: server/pubsub/pubsub.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pubsub import ( "context" "errors" "fmt" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // PubSub provider interface, used to signal pipeline state changes to WebUI. type PubSub interface { // Publish pushes a state change to all subscribers, // that have at least subscribed to one of the topics we publish it under. Publish(context.Context, Topics, Message) error // Subscribe gets all state changes that match the same topic. // If multiple topics are subscribed, and a message also match multiple, // the implementation takes care of deduplication. Subscribe(context.Context, Topics, Receiver) error } // Message defines a published message. type Message struct { // ID identifies this message. ID string `json:"id,omitempty"` // Data is the actual data in the entry. Data []byte `json:"data"` } // Receiver receives published messages. type Receiver func(Message) // Topics are key-value pairs, messages are filtered upon // the the key is the base-key and the value to the sub-key. type Topics map[string]struct{} func GetRepoTopic(r *model.Repo) string { return fmt.Sprintf("repo.id.%d", r.ID) } const PublicTopic = "public" var ErrNoTopic = errors.New("no topic specified") ================================================ FILE: server/pubsub/pubsub_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pubsub_test import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" ) func TestPubSub(t *testing.T) { // for each pubsub adapter (currently we have only one) t.Run("in_memory", func(t *testing.T) { testPubSub(t, memory.New()) }) } func testPubSub(t *testing.T, adapter pubsub.PubSub) { assert.NoError(t, adapter.Publish(t.Context(), pubsub.Topics{"a": {}}, pubsub.Message{ID: "1", Data: []byte(`dummy`)}), "expect no issue publish to a pubsub with no subscribers", ) t.Run("test deduplication asumptions", func(t *testing.T) { treeTopicCloser, treeTopicGetMSGs := genTestSub(t, adapter, pubsub.Topics{"tree": {}}) t.Cleanup(treeTopicCloser) closer, getMSGs := genTestSub(t, adapter, pubsub.Topics{"apples": {}, "tree": {}, "raspberry": {}}) t.Cleanup(closer) assert.Len(t, getMSGs(), 0) time.Sleep(10 * time.Millisecond) assert.NoError(t, adapter.Publish(t.Context(), pubsub.Topics{"tree": {}, "raspberry": {}, "tails": {}}, pubsub.Message{ID: "2"})) assert.NoError(t, adapter.Publish(t.Context(), pubsub.Topics{"apples": {}, "raspberry": {}, "tails": {}}, pubsub.Message{ID: "3"})) time.Sleep(100 * time.Millisecond) if assert.Len(t, getMSGs(), 2) { assert.ElementsMatch(t, []string{"2", "3"}, messagesToIDs(getMSGs())) } assert.EqualValues(t, "2", treeTopicGetMSGs()[0].ID) }) t.Run("test adapters calc for strange input", func(t *testing.T) { t.Run("empty topic", func(t *testing.T) { assert.Error(t, adapter.Subscribe(t.Context(), nil, func(pubsub.Message) {})) assert.Error(t, adapter.Subscribe(t.Context(), pubsub.Topics{}, func(pubsub.Message) {})) assert.Error(t, adapter.Publish(t.Context(), nil, pubsub.Message{})) assert.Error(t, adapter.Publish(t.Context(), pubsub.Topics{}, pubsub.Message{})) }) }) } func genTestSub(t *testing.T, adapter pubsub.PubSub, topics pubsub.Topics) (close func(), getMSGs func() []pubsub.Message) { ctx, closer := context.WithCancelCause(t.Context()) var mu sync.Mutex var messages []pubsub.Message go func() { err := adapter.Subscribe(ctx, topics, func(m pubsub.Message) { mu.Lock() messages = append(messages, m) mu.Unlock() }) assert.NoError(t, err) }() return func() { closer(nil) }, func() []pubsub.Message { mu.Lock() defer mu.Unlock() cp := make([]pubsub.Message, len(messages)) copy(cp, messages) return cp } } func messagesToIDs(msgs []pubsub.Message) (ids []string) { for i := range msgs { ids = append(ids, msgs[i].ID) } return ids } ================================================ FILE: server/queue/fifo.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package queue import ( "container/list" "context" "errors" "slices" "sync" "time" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) type entry struct { item *model.Task done chan bool error error deadline time.Time } type worker struct { agentID int64 filter FilterFn channel chan *model.Task stop context.CancelCauseFunc } type fifo struct { sync.Mutex ctx context.Context workers map[*worker]struct{} running map[string]*entry pending *list.List waitingOnDeps *list.List extension time.Duration paused bool } // processTimeInterval is the time till the queue rearranges things, // as the agent pull in 10 milliseconds we should also give them work asap. const processTimeInterval = 100 * time.Millisecond // NewMemoryQueue returns a new fifo queue. func NewMemoryQueue(ctx context.Context) Queue { q := &fifo{ ctx: ctx, workers: map[*worker]struct{}{}, running: map[string]*entry{}, pending: list.New(), waitingOnDeps: list.New(), extension: constant.TaskTimeout, paused: false, } go q.process() return q } // PushAtOnce pushes multiple tasks to the tail of this queue. func (q *fifo) PushAtOnce(_ context.Context, tasks []*model.Task) error { q.Lock() for _, task := range tasks { q.pending.PushBack(task) } q.Unlock() return nil } // Poll retrieves and removes a task head of this queue. func (q *fifo) Poll(c context.Context, agentID int64, filter FilterFn) (*model.Task, error) { q.Lock() ctx, stop := context.WithCancelCause(c) w := &worker{ agentID: agentID, channel: make(chan *model.Task, 1), filter: filter, stop: stop, } q.workers[w] = struct{}{} q.Unlock() for { select { case <-ctx.Done(): q.Lock() delete(q.workers, w) q.Unlock() return nil, ctx.Err() case t := <-w.channel: return t, nil } } } // Done signals the task is complete. func (q *fifo) Done(_ context.Context, id string, exitStatus model.StatusValue) error { return q.finished([]string{id}, exitStatus, nil) } // Error signals the task is done with an error. func (q *fifo) Error(_ context.Context, id string, err error) error { return q.finished([]string{id}, model.StatusFailure, err) } // ErrorAtOnce signals multiple tasks are done and complete with an error. // If still pending they will just get removed from the queue. func (q *fifo) ErrorAtOnce(_ context.Context, ids []string, err error) error { if errors.Is(err, ErrCancel) { return q.finished(ids, model.StatusKilled, err) } return q.finished(ids, model.StatusFailure, err) } // locks the queue itself! func (q *fifo) finished(ids []string, exitStatus model.StatusValue, err error) error { q.Lock() defer q.Unlock() // it's an external error so we wrap it err = NewErrExternal(err) var errs []error // we first process the tasks itself for _, id := range ids { if taskEntry, ok := q.running[id]; ok { taskEntry.error = err close(taskEntry.done) delete(q.running, id) } else { errs = append(errs, q.removeFromPendingAndWaiting(id)) } } // next we aim for there dependencies // we do this because in our ids list there could be tasks and its dependencies // so not to mess things up for _, id := range ids { q.updateDepStatusInQueue(id, exitStatus) } return errors.Join(errs...) } // Wait waits until the item is done executing. // Also signals via error ErrCancel if workflow got canceled. func (q *fifo) Wait(ctx context.Context, taskID string) error { q.Lock() state := q.running[taskID] q.Unlock() if state != nil { select { case <-ctx.Done(): case <-state.done: // check if we have a wrapped cancel error and unwrap it if errors.Is(state.error, ErrCancel) { return ErrCancel } // or return queue errors and no workflow errors if !errors.Is(state.error, new(ErrExternal)) { return state.error } } } return nil } // Extend extends the task execution deadline. func (q *fifo) Extend(_ context.Context, agentID int64, taskID string) error { q.Lock() defer q.Unlock() state, ok := q.running[taskID] if ok { if state.item.AgentID != agentID { return ErrAgentMissMatch } state.deadline = time.Now().Add(q.extension) return nil } return ErrNotFound } // Info returns internal queue information. func (q *fifo) Info(_ context.Context) InfoT { q.Lock() stats := InfoT{} stats.Stats.Workers = len(q.workers) stats.Stats.Pending = q.pending.Len() stats.Stats.WaitingOnDeps = q.waitingOnDeps.Len() stats.Stats.Running = len(q.running) for element := q.pending.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) stats.Pending = append(stats.Pending, task) } for element := q.waitingOnDeps.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) stats.WaitingOnDeps = append(stats.WaitingOnDeps, task) } for _, entry := range q.running { stats.Running = append(stats.Running, entry.item) } stats.Paused = q.paused q.Unlock() return stats } // Pause stops the queue from handing out new work items in Poll. func (q *fifo) Pause() { q.Lock() q.paused = true q.Unlock() } // Resume starts the queue again. func (q *fifo) Resume() { q.Lock() q.paused = false q.Unlock() } // KickAgentWorkers kicks all workers for a given agent. func (q *fifo) KickAgentWorkers(agentID int64) { q.Lock() defer q.Unlock() for worker := range q.workers { if worker.agentID == agentID { worker.stop(ErrWorkerKicked) delete(q.workers, worker) } } } // helper function that loops through the queue and attempts to // match the item to a single subscriber until context got cancel. func (q *fifo) process() { for { select { case <-time.After(processTimeInterval): case <-q.ctx.Done(): return } q.Lock() if q.paused { q.Unlock() continue } q.resubmitExpiredPipelines() q.filterWaiting() for pending, worker := q.assignToWorker(); pending != nil && worker != nil; pending, worker = q.assignToWorker() { task, _ := pending.Value.(*model.Task) task.AgentID = worker.agentID delete(q.workers, worker) q.pending.Remove(pending) q.running[task.ID] = &entry{ item: task, done: make(chan bool), deadline: time.Now().Add(q.extension), } worker.channel <- task } q.Unlock() } } func (q *fifo) filterWaiting() { // resubmits all waiting tasks to pending, deps may have cleared for element := q.waitingOnDeps.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) q.pending.PushBack(task) } // rebuild waitingDeps q.waitingOnDeps = list.New() var filtered []*list.Element for element := q.pending.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) if q.depsInQueue(task) { log.Debug().Msgf("queue: waiting due to unmet dependencies %v", task.ID) q.waitingOnDeps.PushBack(task) filtered = append(filtered, element) } } // filter waiting tasks for _, f := range filtered { q.pending.Remove(f) } } func (q *fifo) assignToWorker() (*list.Element, *worker) { var bestWorker *worker var bestScore int for element := q.pending.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) log.Debug().Msgf("queue: trying to assign task: %v with deps %v", task.ID, task.Dependencies) for worker := range q.workers { matched, score := worker.filter(task) if matched && score > bestScore { bestWorker = worker bestScore = score } } if bestWorker != nil { log.Debug().Msgf("queue: assigned task: %v with deps %v to worker with score %d", task.ID, task.Dependencies, bestScore) return element, bestWorker } } return nil, nil } func (q *fifo) resubmitExpiredPipelines() { for taskID, taskState := range q.running { if time.Now().After(taskState.deadline) { log.Info().Msgf("queue: resubmitting expired task %s", taskID) taskState.error = ErrTaskExpired q.pending.PushFront(taskState.item) delete(q.running, taskID) close(taskState.done) } } } func (q *fifo) depsInQueue(task *model.Task) bool { for element := q.pending.Front(); element != nil; element = element.Next() { possibleDep, ok := element.Value.(*model.Task) log.Debug().Msgf("queue: pending right now: %v", possibleDep.ID) for _, dep := range task.Dependencies { if ok && possibleDep.ID == dep { return true } } } for possibleDepID := range q.running { log.Debug().Msgf("queue: running right now: %v", possibleDepID) if slices.Contains(task.Dependencies, possibleDepID) { return true } } return false } // expects the q to be currently owned e.g. locked by caller! func (q *fifo) updateDepStatusInQueue(taskID string, status model.StatusValue) { for element := q.pending.Front(); element != nil; element = element.Next() { pending, _ := element.Value.(*model.Task) for _, dep := range pending.Dependencies { if taskID == dep { pending.DepStatus[dep] = status } } } for _, running := range q.running { for _, dep := range running.item.Dependencies { if taskID == dep { running.item.DepStatus[dep] = status } } } for element := q.waitingOnDeps.Front(); element != nil; element = element.Next() { waiting, _ := element.Value.(*model.Task) for _, dep := range waiting.Dependencies { if taskID == dep { waiting.DepStatus[dep] = status } } } } // expects the q to be currently owned e.g. locked by caller! func (q *fifo) removeFromPendingAndWaiting(taskID string) error { log.Debug().Msgf("queue: trying to remove %s", taskID) // we assume pending first for element := q.pending.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) if task.ID == taskID { log.Debug().Msgf("queue: %s is removed from pending", taskID) _ = q.pending.Remove(element) return nil } } // well looks like it's waiting for element := q.waitingOnDeps.Front(); element != nil; element = element.Next() { task, _ := element.Value.(*model.Task) if task.ID == taskID { log.Debug().Msgf("queue: %s is removed from waitingOnDeps", taskID) _ = q.waitingOnDeps.Remove(element) return nil } } // well it could not be found return ErrNotFound } ================================================ FILE: server/queue/fifo_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package queue import ( "context" "errors" "fmt" "sync" "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var ( filterFnTrue = func(*model.Task) (bool, int) { return true, 1 } genDummyTask = func() *model.Task { return &model.Task{ ID: "1", Data: []byte("{}"), } } waitForProcess = func() { time.Sleep(processTimeInterval + 50*time.Millisecond) } ) func setupTestQueue(t *testing.T) (context.Context, context.CancelCauseFunc, *fifo) { ctx, cancel := context.WithCancelCause(t.Context()) t.Cleanup(func() { cancel(nil) }) q, _ := NewMemoryQueue(ctx).(*fifo) if q == nil { t.Fatal("Failed to create queue") } return ctx, cancel, q } func TestFifoBasicOperations(t *testing.T) { ctx, cancel, q := setupTestQueue(t) defer cancel(nil) t.Run("push poll done lifecycle", func(t *testing.T) { dummyTask := genDummyTask() assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask})) waitForProcess() info := q.Info(ctx) assert.Len(t, info.Pending, 1) got, err := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, err) assert.Equal(t, dummyTask, got) waitForProcess() info = q.Info(ctx) assert.Len(t, info.Pending, 0) assert.Len(t, info.Running, 1) // Edge case: verify task can't be polled again while running pollCtx, pollCancel := context.WithTimeout(ctx, 100*time.Millisecond) _, err = q.Poll(pollCtx, 2, filterFnTrue) pollCancel() assert.Error(t, err) // Should timeout/cancel, not return the same task assert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess)) waitForProcess() info = q.Info(ctx) assert.Len(t, info.Running, 0) // Edge case: Done on already completed task should handle gracefully err = q.Done(ctx, got.ID, model.StatusSuccess) // Document current behavior - should either error or be idempotent if err != nil { assert.Error(t, err) } }) t.Run("error handling", func(t *testing.T) { task1 := &model.Task{ID: "task-error-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1})) waitForProcess() got, _ := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, q.Error(ctx, got.ID, fmt.Errorf("test error"))) waitForProcess() info := q.Info(ctx) assert.Len(t, info.Running, 0) assert.Error(t, q.Error(ctx, "totally-fake-id", fmt.Errorf("test error"))) // Edge case: Error on task that's already errored err := q.Error(ctx, got.ID, fmt.Errorf("double error")) // Should either error or be idempotent if err != nil { assert.Error(t, err) } }) t.Run("external error filtered by Wait", func(t *testing.T) { // Test that external errors (from Error/ErrorAtOnce) are wrapped as ErrExternal // and filtered out by Wait(), while internal errors like context cancellation // are passed through // Test 1: External error is filtered by Wait task1 := &model.Task{ID: "wait-external-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1})) waitForProcess() got1, err := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, err) // Start waiting on the task waitDone := make(chan error, 1) go func() { waitDone <- q.Wait(ctx, got1.ID) }() time.Sleep(10 * time.Millisecond) // Report an external error (agent reported error) externalErr := fmt.Errorf("agent reported error") assert.NoError(t, q.Error(ctx, got1.ID, externalErr)) // Wait should return nil (external error filtered out) select { case err := <-waitDone: assert.NoError(t, err, "Wait should filter ErrExternal and return nil") case <-time.After(time.Second): t.Fatal("Wait should have returned") } // Test 2: Internal error (context cancellation) passes through Wait task2 := &model.Task{ID: "wait-internal-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2})) waitForProcess() got2, err := q.Poll(ctx, 2, filterFnTrue) assert.NoError(t, err) waitCtx, waitCancel := context.WithCancelCause(ctx) waitDone2 := make(chan error, 1) go func() { waitDone2 <- q.Wait(waitCtx, got2.ID) }() time.Sleep(10 * time.Millisecond) waitCancel(nil) // Context cancellation should cause Wait to return (internal error handling) select { case err := <-waitDone2: // Wait returns nil when context is canceled (normal behavior) assert.NoError(t, err, "Wait should return nil when context is canceled") case <-time.After(time.Second): t.Fatal("Wait should return when context is canceled") } // Clean up assert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess)) waitForProcess() // Test 3: Multiple waiters all get nil when external error occurs task3 := &model.Task{ID: "wait-multi-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task3})) waitForProcess() got3, err := q.Poll(ctx, 3, filterFnTrue) assert.NoError(t, err) // Start multiple waiters numWaiters := 3 waitResults := make(chan error, numWaiters) for i := 0; i < numWaiters; i++ { go func() { waitResults <- q.Wait(ctx, got3.ID) }() } time.Sleep(10 * time.Millisecond) // Report an external error batchErr := fmt.Errorf("external batch failure") assert.NoError(t, q.ErrorAtOnce(ctx, []string{got3.ID}, batchErr)) // All waiters should return nil (external error filtered) for i := 0; i < numWaiters; i++ { select { case err := <-waitResults: assert.NoError(t, err, "All waiters should get nil when ErrExternal is filtered") case <-time.After(time.Second): t.Fatalf("Waiter %d didn't return in time", i) } } }) t.Run("error at once", func(t *testing.T) { task1 := &model.Task{ID: "batch-1"} task2 := &model.Task{ID: "batch-2"} task3 := &model.Task{ID: "batch-3"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2, task3})) waitForProcess() got1, _ := q.Poll(ctx, 1, filterFnTrue) got2, _ := q.Poll(ctx, 2, filterFnTrue) assert.NoError(t, q.ErrorAtOnce(ctx, []string{got1.ID, got2.ID}, fmt.Errorf("batch error"))) waitForProcess() info := q.Info(ctx) assert.Len(t, info.Running, 0) assert.Len(t, info.Pending, 1) got3, _ := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, q.Done(ctx, got3.ID, model.StatusSuccess)) waitForProcess() task4 := &model.Task{ID: "batch-4"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task4})) waitForProcess() got4, _ := q.Poll(ctx, 1, filterFnTrue) err := q.ErrorAtOnce(ctx, []string{got4.ID, "fake-1", "fake-2"}, fmt.Errorf("test")) assert.Error(t, err) assert.ErrorIs(t, err, ErrNotFound) waitForProcess() info = q.Info(ctx) assert.Len(t, info.Running, 0) // Edge case: ErrorAtOnce with empty slice err = q.ErrorAtOnce(ctx, []string{}, fmt.Errorf("no tasks")) assert.NoError(t, err) // Should handle gracefully, potentially no-op // Edge case: ErrorAtOnce with nil error task5 := &model.Task{ID: "batch-5"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task5})) waitForProcess() got5, _ := q.Poll(ctx, 3, filterFnTrue) err = q.ErrorAtOnce(ctx, []string{got5.ID}, nil) assert.NoError(t, err) // Should handle nil error gracefully waitForProcess() }) t.Run("error at once with waiting deps", func(t *testing.T) { task5 := &model.Task{ID: "deps-cancel-5"} task6 := &model.Task{ ID: "deps-cancel-6", Dependencies: []string{"deps-cancel-5"}, DepStatus: make(map[string]model.StatusValue), } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task5, task6})) waitForProcess() info := q.Info(ctx) assert.Equal(t, 1, info.Stats.WaitingOnDeps) assert.NoError(t, q.ErrorAtOnce(ctx, []string{"deps-cancel-5", "deps-cancel-6"}, fmt.Errorf("canceled"))) waitForProcess() info = q.Info(ctx) assert.Equal(t, 0, info.Stats.WaitingOnDeps) assert.Len(t, info.Pending, 0) // Edge case: verify both tasks are actually gone, not stuck somewhere assert.Len(t, info.Running, 0) assert.Len(t, info.WaitingOnDeps, 0) }) t.Run("error at once cancellation", func(t *testing.T) { task1 := &model.Task{ID: "cancel-prop-1"} task2 := &model.Task{ ID: "cancel-prop-2", Dependencies: []string{"cancel-prop-1"}, DepStatus: make(map[string]model.StatusValue), RunOn: []string{"success", "failure"}, } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2})) waitForProcess() got1, _ := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, q.ErrorAtOnce(ctx, []string{got1.ID}, ErrCancel)) waitForProcess() waitForProcess() got2, _ := q.Poll(ctx, 2, filterFnTrue) assert.Equal(t, model.StatusKilled, got2.DepStatus["cancel-prop-1"]) // Edge case: verify ErrCancel results in StatusKilled not StatusFailure assert.NotEqual(t, model.StatusFailure, got2.DepStatus["cancel-prop-1"]) assert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess)) waitForProcess() }) t.Run("pause resume", func(t *testing.T) { dummyTask := &model.Task{ID: "pause-1"} var wg sync.WaitGroup wg.Add(1) go func() { _, _ = q.Poll(ctx, 99, filterFnTrue) wg.Done() }() q.Pause() t0 := time.Now() assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask})) waitForProcess() // Edge case: verify queue is actually paused info := q.Info(ctx) assert.True(t, info.Paused) assert.Len(t, info.Pending, 1) assert.Len(t, info.Running, 0) q.Resume() wg.Wait() assert.Greater(t, time.Since(t0), 20*time.Millisecond) // Edge case: verify queue is unpaused info = q.Info(ctx) assert.False(t, info.Paused) // Edge case: multiple pause/resume cycles task2 := &model.Task{ID: "pause-2"} q.Pause() q.Pause() // Double pause assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2})) waitForProcess() q.Resume() q.Resume() // Double resume waitForProcess() got, _ := q.Poll(ctx, 99, filterFnTrue) assert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess)) waitForProcess() }) } func TestFifoDependencies(t *testing.T) { ctx, cancel, q := setupTestQueue(t) defer cancel(nil) t.Run("basic dependency handling", func(t *testing.T) { task1 := &model.Task{ID: "dep-basic-1"} task2 := &model.Task{ ID: "dep-basic-2", Dependencies: []string{"dep-basic-1"}, DepStatus: make(map[string]model.StatusValue), } task3 := &model.Task{ ID: "dep-basic-3", Dependencies: []string{"dep-basic-1"}, DepStatus: make(map[string]model.StatusValue), RunOn: []string{"success", "failure"}, } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2, task3, task1})) waitForProcess() info := q.Info(ctx) assert.Equal(t, 2, info.Stats.WaitingOnDeps) got, err := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, err) assert.Equal(t, task1, got) assert.NoError(t, q.Error(ctx, got.ID, fmt.Errorf("exit code 1"))) waitForProcess() got, err = q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, err) assert.Equal(t, task2, got) assert.False(t, got.ShouldRun()) assert.Equal(t, model.StatusFailure, got.DepStatus["dep-basic-1"]) waitForProcess() got, err = q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, err) assert.Equal(t, task3, got) assert.True(t, got.ShouldRun()) assert.Equal(t, model.StatusFailure, got.DepStatus["dep-basic-1"]) waitForProcess() info = q.Info(ctx) assert.Equal(t, 0, info.Stats.WaitingOnDeps) // Edge case: verify DepStatus is correctly set before polling assert.NotEmpty(t, task2.DepStatus) assert.NotEmpty(t, task3.DepStatus) }) t.Run("multiple dependencies", func(t *testing.T) { task1 := &model.Task{ID: "multi-dep-1"} task2 := &model.Task{ID: "multi-dep-2"} task3 := &model.Task{ ID: "multi-dep-3", Dependencies: []string{"multi-dep-1", "multi-dep-2"}, DepStatus: make(map[string]model.StatusValue), } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2, task3, task1})) waitForProcess() got1, _ := q.Poll(ctx, 1, filterFnTrue) got2, _ := q.Poll(ctx, 2, filterFnTrue) gotIDs := map[string]bool{got1.ID: true, got2.ID: true} assert.True(t, gotIDs["multi-dep-1"] && gotIDs["multi-dep-2"]) if got1.ID == "multi-dep-1" { assert.NoError(t, q.Done(ctx, got1.ID, model.StatusSuccess)) assert.NoError(t, q.Error(ctx, got2.ID, fmt.Errorf("failed"))) } else { assert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess)) assert.NoError(t, q.Error(ctx, got1.ID, fmt.Errorf("failed"))) } waitForProcess() got3, err := q.Poll(ctx, 3, filterFnTrue) assert.NoError(t, err) assert.Contains(t, got3.DepStatus, "multi-dep-1") assert.Contains(t, got3.DepStatus, "multi-dep-2") assert.True(t, (got3.DepStatus["multi-dep-1"] == model.StatusSuccess && got3.DepStatus["multi-dep-2"] == model.StatusFailure) || (got3.DepStatus["multi-dep-1"] == model.StatusFailure && got3.DepStatus["multi-dep-2"] == model.StatusSuccess)) assert.False(t, got3.ShouldRun()) // Edge case: verify both deps are tracked assert.Len(t, got3.DepStatus, 2) assert.NoError(t, q.Done(ctx, got3.ID, model.StatusSkipped)) waitForProcess() }) t.Run("transitive dependencies", func(t *testing.T) { task1 := &model.Task{ID: "trans-1"} task2 := &model.Task{ ID: "trans-2", Dependencies: []string{"trans-1"}, DepStatus: make(map[string]model.StatusValue), } task3 := &model.Task{ ID: "trans-3", Dependencies: []string{"trans-2"}, DepStatus: make(map[string]model.StatusValue), } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task2, task3, task1})) waitForProcess() got, _ := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, q.Error(ctx, got.ID, fmt.Errorf("exit code 1"))) waitForProcess() got, _ = q.Poll(ctx, 2, filterFnTrue) assert.False(t, got.ShouldRun()) assert.NoError(t, q.Done(ctx, got.ID, model.StatusSkipped)) waitForProcess() got, _ = q.Poll(ctx, 3, filterFnTrue) assert.Equal(t, model.StatusSkipped, got.DepStatus["trans-2"]) assert.False(t, got.ShouldRun()) // Edge case: verify transitive failure propagates correctly // task3 should see trans-2 as skipped, not trans-1's status assert.NotContains(t, got.DepStatus, "trans-1") assert.NoError(t, q.Done(ctx, got.ID, model.StatusSkipped)) waitForProcess() }) t.Run("dependency status propagation", func(t *testing.T) { task1 := &model.Task{ID: "prop-1"} task2 := &model.Task{ ID: "prop-2", Dependencies: []string{"prop-1"}, DepStatus: make(map[string]model.StatusValue), } task3 := &model.Task{ ID: "prop-3", Dependencies: []string{"prop-1"}, DepStatus: make(map[string]model.StatusValue), RunOn: []string{"success", "failure"}, } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2, task3})) waitForProcess() info := q.Info(ctx) assert.Equal(t, 2, info.Stats.WaitingOnDeps) got1, _ := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, q.Done(ctx, got1.ID, model.StatusSuccess)) waitForProcess() got2, _ := q.Poll(ctx, 2, filterFnTrue) got3, _ := q.Poll(ctx, 3, filterFnTrue) assert.Equal(t, model.StatusSuccess, got2.DepStatus["prop-1"]) assert.Equal(t, model.StatusSuccess, got3.DepStatus["prop-1"]) // Edge case: verify both tasks can be polled concurrently assert.NotEqual(t, got2.ID, got3.ID) assert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess)) assert.NoError(t, q.Done(ctx, got3.ID, model.StatusSuccess)) waitForProcess() task4 := &model.Task{ID: "prop-4"} task5 := &model.Task{ ID: "prop-5", Dependencies: []string{"prop-4"}, DepStatus: make(map[string]model.StatusValue), } task6 := &model.Task{ ID: "prop-6", Dependencies: []string{"prop-4"}, DepStatus: make(map[string]model.StatusValue), RunOn: []string{"success", "failure"}, } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task4, task5, task6})) waitForProcess() got4, _ := q.Poll(ctx, 4, filterFnTrue) assert.NoError(t, q.Error(ctx, got4.ID, fmt.Errorf("failed"))) waitForProcess() got5, _ := q.Poll(ctx, 5, filterFnTrue) assert.Equal(t, model.StatusFailure, got5.DepStatus["prop-4"]) assert.False(t, got5.ShouldRun()) got6, _ := q.Poll(ctx, 6, filterFnTrue) assert.Equal(t, model.StatusFailure, got6.DepStatus["prop-4"]) assert.True(t, got6.ShouldRun()) // Edge case: complete dependent tasks assert.NoError(t, q.Done(ctx, got5.ID, model.StatusSkipped)) assert.NoError(t, q.Done(ctx, got6.ID, model.StatusSuccess)) waitForProcess() }) // Edge case: circular dependency detection (should be handled or cause issue) t.Run("circular dependencies", func(t *testing.T) { task1 := &model.Task{ ID: "circ-1", Dependencies: []string{"circ-2"}, DepStatus: make(map[string]model.StatusValue), } task2 := &model.Task{ ID: "circ-2", Dependencies: []string{"circ-1"}, DepStatus: make(map[string]model.StatusValue), } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1, task2})) waitForProcess() info := q.Info(ctx) // Both should be waiting on deps - this is a deadlock scenario assert.Equal(t, 2, info.Stats.WaitingOnDeps) assert.Len(t, info.Pending, 0) // Verify they never become available for polling pollCtx, pollCancel := context.WithTimeout(ctx, 200*time.Millisecond) _, err := q.Poll(pollCtx, 99, filterFnTrue) pollCancel() assert.Error(t, err) // Should timeout // Clean up the deadlocked tasks assert.NoError(t, q.ErrorAtOnce(ctx, []string{"circ-1", "circ-2"}, fmt.Errorf("circular dep"))) waitForProcess() }) // Edge case: dependency on non-existent task // NOTE: This reveals a potential issue - the queue doesn't validate dependencies exist. // If a dependency was never added to the queue, the task will run immediately since // depsInQueue() only checks currently pending/running tasks, not if deps will arrive. t.Run("non-existent dependency", func(t *testing.T) { task1 := &model.Task{ ID: "orphan-1", Dependencies: []string{"does-not-exist"}, DepStatus: make(map[string]model.StatusValue), } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task1})) waitForProcess() info := q.Info(ctx) // Current implementation: task doesn't wait if dependency not in queue // This means tasks with typos in dependency names will run immediately! assert.Equal(t, 0, info.Stats.WaitingOnDeps) assert.Len(t, info.Pending, 1) // Task will be available for polling even though dependency doesn't exist got, err := q.Poll(ctx, 99, filterFnTrue) assert.NoError(t, err) assert.Equal(t, "orphan-1", got.ID) // DepStatus will be empty since dependency never completed assert.Empty(t, got.DepStatus) // Clean up assert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess)) waitForProcess() }) // Edge case: dependency added AFTER dependent task (race condition) t.Run("dependency added after dependent", func(t *testing.T) { // Push dependent task first dependent := &model.Task{ ID: "late-dep-child", Dependencies: []string{"late-dep-parent"}, DepStatus: make(map[string]model.StatusValue), } assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dependent})) waitForProcess() // At this point, dependent doesn't see parent in queue, so it won't wait info := q.Info(ctx) // Dependent should NOT be waiting since parent doesn't exist yet initialWaiting := info.Stats.WaitingOnDeps // Now add the parent task parent := &model.Task{ID: "late-dep-parent"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{parent})) waitForProcess() // After filterWaiting runs, dependent SHOULD now see parent and wait info = q.Info(ctx) // The implementation calls filterWaiting() which rechecks dependencies // So dependent should now be waiting assert.Greater(t, info.Stats.WaitingOnDeps, initialWaiting, "dependent should start waiting once parent is added") // Complete parent first gotParent, _ := q.Poll(ctx, 1, filterFnTrue) assert.Equal(t, "late-dep-parent", gotParent.ID, "parent should be polled first") assert.NoError(t, q.Done(ctx, gotParent.ID, model.StatusSuccess)) waitForProcess() // Now child should be unblocked with parent's status gotChild, _ := q.Poll(ctx, 2, filterFnTrue) assert.Equal(t, "late-dep-child", gotChild.ID) assert.Equal(t, model.StatusSuccess, gotChild.DepStatus["late-dep-parent"]) assert.NoError(t, q.Done(ctx, gotChild.ID, model.StatusSuccess)) waitForProcess() }) } func TestFifoLeaseManagement(t *testing.T) { ctx, cancel, q := setupTestQueue(t) defer cancel(nil) t.Run("lease expiration", func(t *testing.T) { q.extension = 0 t.Cleanup(func() { q.extension = 50 * time.Millisecond }) dummyTask := &model.Task{ID: "lease-exp-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask})) waitForProcess() got, err := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, err) errCh := make(chan error, 1) go func() { errCh <- q.Wait(ctx, got.ID) }() waitForProcess() select { case werr := <-errCh: assert.Error(t, werr) // Edge case: verify error is ErrTaskExpired assert.ErrorIs(t, werr, ErrTaskExpired) case <-time.After(2 * time.Second): t.Fatal("timeout waiting for Wait to return") } info := q.Info(ctx) assert.Len(t, info.Pending, 1) // Edge case: verify task was resubmitted to front of queue got2, _ := q.Poll(ctx, 1, filterFnTrue) assert.Equal(t, got.ID, got2.ID) // Same task resubmitted assert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess)) waitForProcess() // Verify cleanup info = q.Info(ctx) assert.Len(t, info.Pending, 0) assert.Len(t, info.Running, 0) }) t.Run("extend lease", func(t *testing.T) { q.extension = 50 * time.Millisecond dummyTask := &model.Task{ID: "extend-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask})) waitForProcess() got, _ := q.Poll(ctx, 5, filterFnTrue) assert.NoError(t, q.Extend(ctx, 5, got.ID)) assert.ErrorIs(t, q.Extend(ctx, 999, got.ID), ErrAgentMissMatch) assert.ErrorIs(t, q.Extend(ctx, 1, got.ID), ErrAgentMissMatch) assert.ErrorIs(t, q.Extend(ctx, 1, "non-existent"), ErrNotFound) // Edge case: extend multiple times rapidly for i := 0; i < 3; i++ { time.Sleep(30 * time.Millisecond) assert.NoError(t, q.Extend(ctx, 5, got.ID)) } info := q.Info(ctx) assert.Len(t, info.Running, 1) assert.Len(t, info.Pending, 0) // Edge case: extend after Done should error assert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess)) waitForProcess() assert.ErrorIs(t, q.Extend(ctx, 5, got.ID), ErrNotFound) // Verify cleanup info = q.Info(ctx) assert.Len(t, info.Pending, 0) assert.Len(t, info.Running, 0) }) t.Run("wait operations", func(t *testing.T) { // Verify queue is clean before starting info := q.Info(ctx) assert.Len(t, info.Pending, 0, "queue should be empty at start of wait operations") assert.Len(t, info.Running, 0, "queue should be empty at start of wait operations") dummyTask := &model.Task{ID: "wait-1"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask})) waitForProcess() got, _ := q.Poll(ctx, 1, filterFnTrue) var wg sync.WaitGroup wg.Add(1) go func() { assert.NoError(t, q.Wait(ctx, got.ID)) wg.Done() }() time.Sleep(time.Millisecond) assert.NoError(t, q.Done(ctx, got.ID, model.StatusSuccess)) wg.Wait() // Edge case: Wait on non-existent task should return immediately assert.NoError(t, q.Wait(ctx, "non-existent")) dummyTask2 := &model.Task{ID: "wait-2"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask2})) waitForProcess() got2, _ := q.Poll(ctx, 1, filterFnTrue) waitCtx, waitCancel := context.WithCancelCause(ctx) errCh := make(chan error, 1) go func() { errCh <- q.Wait(waitCtx, got2.ID) }() time.Sleep(50 * time.Millisecond) waitCancel(nil) select { case err := <-errCh: assert.NoError(t, err) case <-time.After(time.Second): t.Fatal("Wait should return when context is canceled") } // Clean up - complete the second wait task assert.NoError(t, q.Done(ctx, got2.ID, model.StatusSuccess)) waitForProcess() // Edge case: multiple concurrent waits on same task dummyTask3 := &model.Task{ID: "wait-3"} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{dummyTask3})) waitForProcess() got3, _ := q.Poll(ctx, 1, filterFnTrue) var wg2 sync.WaitGroup wg2.Add(3) for i := 0; i < 3; i++ { go func() { assert.NoError(t, q.Wait(ctx, got3.ID)) wg2.Done() }() } time.Sleep(10 * time.Millisecond) assert.NoError(t, q.Done(ctx, got3.ID, model.StatusSuccess)) wg2.Wait() // Verify cleanup info = q.Info(ctx) assert.Len(t, info.Pending, 0) assert.Len(t, info.Running, 0) }) } func TestFifoWorkerManagement(t *testing.T) { ctx, cancel, q := setupTestQueue(t) defer cancel(nil) t.Run("poll with context cancellation", func(t *testing.T) { pollCtx, pollCancel := context.WithCancelCause(ctx) errCh := make(chan error, 1) go func() { _, err := q.Poll(pollCtx, 1, filterFnTrue) errCh <- err }() time.Sleep(50 * time.Millisecond) pollCancel(nil) select { case err := <-errCh: assert.ErrorIs(t, err, context.Canceled) case <-time.After(time.Second): t.Fatal("Poll should return when context is canceled") } // Edge case: verify worker is cleaned up info := q.Info(ctx) assert.Equal(t, 0, info.Stats.Workers) }) t.Run("kick agent workers", func(t *testing.T) { pollResults := make(chan error, 5) for i := 0; i < 5; i++ { go func() { _, err := q.Poll(ctx, 42, filterFnTrue) pollResults <- err }() } time.Sleep(50 * time.Millisecond) // Edge case: verify workers are registered before kicking info := q.Info(ctx) assert.Equal(t, 5, info.Stats.Workers) q.KickAgentWorkers(42) kickedCount := 0 for i := 0; i < 5; i++ { select { case err := <-pollResults: if errors.Is(err, context.Canceled) { kickedCount++ } case <-time.After(time.Second): t.Fatal("expected all workers to be kicked") } } assert.Equal(t, 5, kickedCount) // Edge case: verify workers are removed after kicking waitForProcess() info = q.Info(ctx) assert.Equal(t, 0, info.Stats.Workers) // Edge case: kick non-existent agent should be no-op q.KickAgentWorkers(999) }) // Edge case: mixed agent workers t.Run("kick specific agent among multiple", func(t *testing.T) { pollResults := make(chan struct { agentID int64 err error }, 10) // Start workers for agent 1 for i := 0; i < 3; i++ { go func() { _, err := q.Poll(ctx, 1, filterFnTrue) pollResults <- struct { agentID int64 err error }{1, err} }() } // Start workers for agent 2 for i := 0; i < 3; i++ { go func() { _, err := q.Poll(ctx, 2, filterFnTrue) pollResults <- struct { agentID int64 err error }{2, err} }() } time.Sleep(50 * time.Millisecond) info := q.Info(ctx) assert.Equal(t, 6, info.Stats.Workers) // Kick only agent 1 q.KickAgentWorkers(1) kickedAgent1 := 0 kickedAgent2 := 0 for i := 0; i < 3; i++ { select { case result := <-pollResults: if errors.Is(result.err, context.Canceled) { if result.agentID == 1 { kickedAgent1++ } else { kickedAgent2++ } } case <-time.After(time.Second): t.Fatal("expected kicked workers to return") } } assert.Equal(t, 3, kickedAgent1) assert.Equal(t, 0, kickedAgent2) // Clean up agent 2 workers q.KickAgentWorkers(2) for i := 0; i < 3; i++ { <-pollResults } }) } func TestFifoLabelBasedScoring(t *testing.T) { ctx, cancel := context.WithCancelCause(t.Context()) defer cancel(nil) q := NewMemoryQueue(ctx) tasks := []*model.Task{ {ID: "1", Labels: map[string]string{"org-id": "123", "platform": "linux"}}, {ID: "2", Labels: map[string]string{"org-id": "456", "platform": "linux"}}, {ID: "3", Labels: map[string]string{"org-id": "123", "platform": "windows"}}, } assert.NoError(t, q.PushAtOnce(ctx, tasks)) filter123 := func(task *model.Task) (bool, int) { if task.Labels["org-id"] == "123" { return true, 20 } return true, 1 } filter456 := func(task *model.Task) (bool, int) { if task.Labels["org-id"] == "456" { return true, 20 } return true, 1 } results := make(chan *model.Task, 2) go func() { task, _ := q.Poll(ctx, 1, filter123) results <- task }() go func() { task, _ := q.Poll(ctx, 2, filter456) results <- task }() receivedTasks := make(map[string]int64) for i := 0; i < 2; i++ { select { case task := <-results: receivedTasks[task.ID] = task.AgentID case <-time.After(time.Second): t.Fatal("Timeout waiting for tasks") } } assert.Contains(t, []string{"1", "3"}, findTaskByAgent(receivedTasks, 1)) assert.Equal(t, "2", findTaskByAgent(receivedTasks, 2)) // Edge case: filter that rejects all tasks filterRejectAll := func(task *model.Task) (bool, int) { return false, 0 } task4 := &model.Task{ID: "4", Labels: map[string]string{"org-id": "789"}} assert.NoError(t, q.PushAtOnce(ctx, []*model.Task{task4})) waitForProcess() pollCtx, pollCancel := context.WithTimeout(ctx, 200*time.Millisecond) _, err := q.Poll(pollCtx, 99, filterRejectAll) pollCancel() assert.Error(t, err) // Should timeout as filter rejects task // Clean up remaining tasks task3, _ := q.Poll(ctx, 1, filterFnTrue) assert.NoError(t, q.Done(ctx, task3.ID, model.StatusSuccess)) task4Got, _ := q.Poll(ctx, 99, filterFnTrue) assert.NoError(t, q.Done(ctx, task4Got.ID, model.StatusSuccess)) waitForProcess() } func TestShouldRunLogic(t *testing.T) { tests := []struct { name string depStatus model.StatusValue runOn []string expected bool }{ {"Success without RunOn", model.StatusSuccess, nil, true}, {"Failure without RunOn", model.StatusFailure, nil, false}, {"Success with failure RunOn", model.StatusSuccess, []string{"failure"}, false}, {"Failure with failure RunOn", model.StatusFailure, []string{"failure"}, true}, {"Success with both RunOn", model.StatusSuccess, []string{"success", "failure"}, true}, {"Skipped without RunOn", model.StatusSkipped, nil, false}, {"Skipped with failure RunOn", model.StatusSkipped, []string{"failure"}, true}, // Edge cases {"Killed without RunOn", model.StatusKilled, nil, false}, {"Killed with failure RunOn", model.StatusKilled, []string{"failure"}, true}, {"Success with success RunOn only", model.StatusSuccess, []string{"success"}, true}, {"Failure with success RunOn only", model.StatusFailure, []string{"success"}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { task := &model.Task{ ID: "2", Dependencies: []string{"1"}, DepStatus: map[string]model.StatusValue{"1": tt.depStatus}, RunOn: tt.runOn, } assert.Equal(t, tt.expected, task.ShouldRun()) }) } // Edge case: multiple dependencies with mixed statuses t.Run("multiple deps mixed status", func(t *testing.T) { task := &model.Task{ ID: "3", Dependencies: []string{"1", "2"}, DepStatus: map[string]model.StatusValue{ "1": model.StatusSuccess, "2": model.StatusFailure, }, RunOn: nil, } // With default RunOn (nil), needs all deps successful assert.False(t, task.ShouldRun()) task.RunOn = []string{"success", "failure"} // With both RunOn, should run regardless assert.True(t, task.ShouldRun()) }) } func findTaskByAgent(tasks map[string]int64, agentID int64) string { for taskID, aid := range tasks { if aid == agentID { return taskID } } return "" } ================================================ FILE: server/queue/mocks/mock_Queue.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/queue" ) // NewMockQueue creates a new instance of MockQueue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockQueue(t interface { mock.TestingT Cleanup(func()) }) *MockQueue { mock := &MockQueue{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockQueue is an autogenerated mock type for the Queue type type MockQueue struct { mock.Mock } type MockQueue_Expecter struct { mock *mock.Mock } func (_m *MockQueue) EXPECT() *MockQueue_Expecter { return &MockQueue_Expecter{mock: &_m.Mock} } // Done provides a mock function for the type MockQueue func (_mock *MockQueue) Done(c context.Context, id string, exitStatus model.StatusValue) error { ret := _mock.Called(c, id, exitStatus) if len(ret) == 0 { panic("no return value specified for Done") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string, model.StatusValue) error); ok { r0 = returnFunc(c, id, exitStatus) } else { r0 = ret.Error(0) } return r0 } // MockQueue_Done_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Done' type MockQueue_Done_Call struct { *mock.Call } // Done is a helper method to define mock.On call // - c context.Context // - id string // - exitStatus model.StatusValue func (_e *MockQueue_Expecter) Done(c interface{}, id interface{}, exitStatus interface{}) *MockQueue_Done_Call { return &MockQueue_Done_Call{Call: _e.mock.On("Done", c, id, exitStatus)} } func (_c *MockQueue_Done_Call) Run(run func(c context.Context, id string, exitStatus model.StatusValue)) *MockQueue_Done_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 model.StatusValue if args[2] != nil { arg2 = args[2].(model.StatusValue) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockQueue_Done_Call) Return(err error) *MockQueue_Done_Call { _c.Call.Return(err) return _c } func (_c *MockQueue_Done_Call) RunAndReturn(run func(c context.Context, id string, exitStatus model.StatusValue) error) *MockQueue_Done_Call { _c.Call.Return(run) return _c } // Error provides a mock function for the type MockQueue func (_mock *MockQueue) Error(c context.Context, id string, err error) error { ret := _mock.Called(c, id, err) if len(ret) == 0 { panic("no return value specified for Error") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string, error) error); ok { r0 = returnFunc(c, id, err) } else { r0 = ret.Error(0) } return r0 } // MockQueue_Error_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Error' type MockQueue_Error_Call struct { *mock.Call } // Error is a helper method to define mock.On call // - c context.Context // - id string // - err error func (_e *MockQueue_Expecter) Error(c interface{}, id interface{}, err interface{}) *MockQueue_Error_Call { return &MockQueue_Error_Call{Call: _e.mock.On("Error", c, id, err)} } func (_c *MockQueue_Error_Call) Run(run func(c context.Context, id string, err error)) *MockQueue_Error_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 error if args[2] != nil { arg2 = args[2].(error) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockQueue_Error_Call) Return(err1 error) *MockQueue_Error_Call { _c.Call.Return(err1) return _c } func (_c *MockQueue_Error_Call) RunAndReturn(run func(c context.Context, id string, err error) error) *MockQueue_Error_Call { _c.Call.Return(run) return _c } // ErrorAtOnce provides a mock function for the type MockQueue func (_mock *MockQueue) ErrorAtOnce(c context.Context, ids []string, err error) error { ret := _mock.Called(c, ids, err) if len(ret) == 0 { panic("no return value specified for ErrorAtOnce") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, []string, error) error); ok { r0 = returnFunc(c, ids, err) } else { r0 = ret.Error(0) } return r0 } // MockQueue_ErrorAtOnce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ErrorAtOnce' type MockQueue_ErrorAtOnce_Call struct { *mock.Call } // ErrorAtOnce is a helper method to define mock.On call // - c context.Context // - ids []string // - err error func (_e *MockQueue_Expecter) ErrorAtOnce(c interface{}, ids interface{}, err interface{}) *MockQueue_ErrorAtOnce_Call { return &MockQueue_ErrorAtOnce_Call{Call: _e.mock.On("ErrorAtOnce", c, ids, err)} } func (_c *MockQueue_ErrorAtOnce_Call) Run(run func(c context.Context, ids []string, err error)) *MockQueue_ErrorAtOnce_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 []string if args[1] != nil { arg1 = args[1].([]string) } var arg2 error if args[2] != nil { arg2 = args[2].(error) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockQueue_ErrorAtOnce_Call) Return(err1 error) *MockQueue_ErrorAtOnce_Call { _c.Call.Return(err1) return _c } func (_c *MockQueue_ErrorAtOnce_Call) RunAndReturn(run func(c context.Context, ids []string, err error) error) *MockQueue_ErrorAtOnce_Call { _c.Call.Return(run) return _c } // Extend provides a mock function for the type MockQueue func (_mock *MockQueue) Extend(c context.Context, agentID int64, workflowID string) error { ret := _mock.Called(c, agentID, workflowID) if len(ret) == 0 { panic("no return value specified for Extend") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, int64, string) error); ok { r0 = returnFunc(c, agentID, workflowID) } else { r0 = ret.Error(0) } return r0 } // MockQueue_Extend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Extend' type MockQueue_Extend_Call struct { *mock.Call } // Extend is a helper method to define mock.On call // - c context.Context // - agentID int64 // - workflowID string func (_e *MockQueue_Expecter) Extend(c interface{}, agentID interface{}, workflowID interface{}) *MockQueue_Extend_Call { return &MockQueue_Extend_Call{Call: _e.mock.On("Extend", c, agentID, workflowID)} } func (_c *MockQueue_Extend_Call) Run(run func(c context.Context, agentID int64, workflowID string)) *MockQueue_Extend_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockQueue_Extend_Call) Return(err error) *MockQueue_Extend_Call { _c.Call.Return(err) return _c } func (_c *MockQueue_Extend_Call) RunAndReturn(run func(c context.Context, agentID int64, workflowID string) error) *MockQueue_Extend_Call { _c.Call.Return(run) return _c } // Info provides a mock function for the type MockQueue func (_mock *MockQueue) Info(c context.Context) queue.InfoT { ret := _mock.Called(c) if len(ret) == 0 { panic("no return value specified for Info") } var r0 queue.InfoT if returnFunc, ok := ret.Get(0).(func(context.Context) queue.InfoT); ok { r0 = returnFunc(c) } else { r0 = ret.Get(0).(queue.InfoT) } return r0 } // MockQueue_Info_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Info' type MockQueue_Info_Call struct { *mock.Call } // Info is a helper method to define mock.On call // - c context.Context func (_e *MockQueue_Expecter) Info(c interface{}) *MockQueue_Info_Call { return &MockQueue_Info_Call{Call: _e.mock.On("Info", c)} } func (_c *MockQueue_Info_Call) Run(run func(c context.Context)) *MockQueue_Info_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } run( arg0, ) }) return _c } func (_c *MockQueue_Info_Call) Return(infoT queue.InfoT) *MockQueue_Info_Call { _c.Call.Return(infoT) return _c } func (_c *MockQueue_Info_Call) RunAndReturn(run func(c context.Context) queue.InfoT) *MockQueue_Info_Call { _c.Call.Return(run) return _c } // KickAgentWorkers provides a mock function for the type MockQueue func (_mock *MockQueue) KickAgentWorkers(agentID int64) { _mock.Called(agentID) return } // MockQueue_KickAgentWorkers_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'KickAgentWorkers' type MockQueue_KickAgentWorkers_Call struct { *mock.Call } // KickAgentWorkers is a helper method to define mock.On call // - agentID int64 func (_e *MockQueue_Expecter) KickAgentWorkers(agentID interface{}) *MockQueue_KickAgentWorkers_Call { return &MockQueue_KickAgentWorkers_Call{Call: _e.mock.On("KickAgentWorkers", agentID)} } func (_c *MockQueue_KickAgentWorkers_Call) Run(run func(agentID int64)) *MockQueue_KickAgentWorkers_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockQueue_KickAgentWorkers_Call) Return() *MockQueue_KickAgentWorkers_Call { _c.Call.Return() return _c } func (_c *MockQueue_KickAgentWorkers_Call) RunAndReturn(run func(agentID int64)) *MockQueue_KickAgentWorkers_Call { _c.Run(run) return _c } // Pause provides a mock function for the type MockQueue func (_mock *MockQueue) Pause() { _mock.Called() return } // MockQueue_Pause_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pause' type MockQueue_Pause_Call struct { *mock.Call } // Pause is a helper method to define mock.On call func (_e *MockQueue_Expecter) Pause() *MockQueue_Pause_Call { return &MockQueue_Pause_Call{Call: _e.mock.On("Pause")} } func (_c *MockQueue_Pause_Call) Run(run func()) *MockQueue_Pause_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockQueue_Pause_Call) Return() *MockQueue_Pause_Call { _c.Call.Return() return _c } func (_c *MockQueue_Pause_Call) RunAndReturn(run func()) *MockQueue_Pause_Call { _c.Run(run) return _c } // Poll provides a mock function for the type MockQueue func (_mock *MockQueue) Poll(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error) { ret := _mock.Called(c, agentID, f) if len(ret) == 0 { panic("no return value specified for Poll") } var r0 *model.Task var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) (*model.Task, error)); ok { return returnFunc(c, agentID, f) } if returnFunc, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) *model.Task); ok { r0 = returnFunc(c, agentID, f) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Task) } } if returnFunc, ok := ret.Get(1).(func(context.Context, int64, queue.FilterFn) error); ok { r1 = returnFunc(c, agentID, f) } else { r1 = ret.Error(1) } return r0, r1 } // MockQueue_Poll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Poll' type MockQueue_Poll_Call struct { *mock.Call } // Poll is a helper method to define mock.On call // - c context.Context // - agentID int64 // - f queue.FilterFn func (_e *MockQueue_Expecter) Poll(c interface{}, agentID interface{}, f interface{}) *MockQueue_Poll_Call { return &MockQueue_Poll_Call{Call: _e.mock.On("Poll", c, agentID, f)} } func (_c *MockQueue_Poll_Call) Run(run func(c context.Context, agentID int64, f queue.FilterFn)) *MockQueue_Poll_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } var arg2 queue.FilterFn if args[2] != nil { arg2 = args[2].(queue.FilterFn) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockQueue_Poll_Call) Return(task *model.Task, err error) *MockQueue_Poll_Call { _c.Call.Return(task, err) return _c } func (_c *MockQueue_Poll_Call) RunAndReturn(run func(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error)) *MockQueue_Poll_Call { _c.Call.Return(run) return _c } // PushAtOnce provides a mock function for the type MockQueue func (_mock *MockQueue) PushAtOnce(c context.Context, tasks []*model.Task) error { ret := _mock.Called(c, tasks) if len(ret) == 0 { panic("no return value specified for PushAtOnce") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, []*model.Task) error); ok { r0 = returnFunc(c, tasks) } else { r0 = ret.Error(0) } return r0 } // MockQueue_PushAtOnce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushAtOnce' type MockQueue_PushAtOnce_Call struct { *mock.Call } // PushAtOnce is a helper method to define mock.On call // - c context.Context // - tasks []*model.Task func (_e *MockQueue_Expecter) PushAtOnce(c interface{}, tasks interface{}) *MockQueue_PushAtOnce_Call { return &MockQueue_PushAtOnce_Call{Call: _e.mock.On("PushAtOnce", c, tasks)} } func (_c *MockQueue_PushAtOnce_Call) Run(run func(c context.Context, tasks []*model.Task)) *MockQueue_PushAtOnce_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 []*model.Task if args[1] != nil { arg1 = args[1].([]*model.Task) } run( arg0, arg1, ) }) return _c } func (_c *MockQueue_PushAtOnce_Call) Return(err error) *MockQueue_PushAtOnce_Call { _c.Call.Return(err) return _c } func (_c *MockQueue_PushAtOnce_Call) RunAndReturn(run func(c context.Context, tasks []*model.Task) error) *MockQueue_PushAtOnce_Call { _c.Call.Return(run) return _c } // Resume provides a mock function for the type MockQueue func (_mock *MockQueue) Resume() { _mock.Called() return } // MockQueue_Resume_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resume' type MockQueue_Resume_Call struct { *mock.Call } // Resume is a helper method to define mock.On call func (_e *MockQueue_Expecter) Resume() *MockQueue_Resume_Call { return &MockQueue_Resume_Call{Call: _e.mock.On("Resume")} } func (_c *MockQueue_Resume_Call) Run(run func()) *MockQueue_Resume_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockQueue_Resume_Call) Return() *MockQueue_Resume_Call { _c.Call.Return() return _c } func (_c *MockQueue_Resume_Call) RunAndReturn(run func()) *MockQueue_Resume_Call { _c.Run(run) return _c } // Wait provides a mock function for the type MockQueue func (_mock *MockQueue) Wait(c context.Context, id string) error { ret := _mock.Called(c, id) if len(ret) == 0 { panic("no return value specified for Wait") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = returnFunc(c, id) } else { r0 = ret.Error(0) } return r0 } // MockQueue_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait' type MockQueue_Wait_Call struct { *mock.Call } // Wait is a helper method to define mock.On call // - c context.Context // - id string func (_e *MockQueue_Expecter) Wait(c interface{}, id interface{}) *MockQueue_Wait_Call { return &MockQueue_Wait_Call{Call: _e.mock.On("Wait", c, id)} } func (_c *MockQueue_Wait_Call) Run(run func(c context.Context, id string)) *MockQueue_Wait_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockQueue_Wait_Call) Return(err error) *MockQueue_Wait_Call { _c.Call.Return(err) return _c } func (_c *MockQueue_Wait_Call) RunAndReturn(run func(c context.Context, id string) error) *MockQueue_Wait_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/queue/persistent.go ================================================ // Copyright 2021 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package queue import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) // WithTaskStore returns a queue that is backed by the TaskStore. This // ensures the task Queue can be restored when the system starts. func WithTaskStore(ctx context.Context, q Queue, s store.Store) Queue { tasks, _ := s.TaskList() if err := q.PushAtOnce(ctx, tasks); err != nil { log.Error().Err(err).Msg("PushAtOnce failed") } return &persistentQueue{q, s} } type persistentQueue struct { Queue store store.Store } // PushAtOnce pushes multiple tasks to the tail of this queue. func (q *persistentQueue) PushAtOnce(c context.Context, tasks []*model.Task) error { // TODO: invent store.NewSession who return context including a session and make TaskInsert & TaskDelete use it for _, task := range tasks { if err := q.store.TaskInsert(task); err != nil { return err } } err := q.Queue.PushAtOnce(c, tasks) if err != nil { for _, task := range tasks { if err := q.store.TaskDelete(task.ID); err != nil { return err } } } return err } // Poll retrieves and removes a task head of this queue. func (q *persistentQueue) Poll(c context.Context, agentID int64, f FilterFn) (*model.Task, error) { task, err := q.Queue.Poll(c, agentID, f) if task != nil { log.Debug().Msgf("pull queue item: %s: remove from backup", task.ID) if deleteErr := q.store.TaskDelete(task.ID); deleteErr != nil { log.Error().Err(deleteErr).Msgf("pull queue item: %s: failed to remove from backup", task.ID) } else { log.Debug().Msgf("pull queue item: %s: successfully removed from backup", task.ID) } } return task, err } // Error signals the task is done with an error. func (q *persistentQueue) Error(c context.Context, id string, err error) error { if err := q.Queue.Error(c, id, err); err != nil { return err } if deleteErr := q.store.TaskDelete(id); deleteErr != nil { if !errors.Is(deleteErr, types.ErrRecordNotExist) { return deleteErr } log.Debug().Msgf("task %s already removed from store", id) } return nil } // ErrorAtOnce signals multiple tasks are done and complete with an error. // If still pending they will just get removed from the queue. func (q *persistentQueue) ErrorAtOnce(c context.Context, ids []string, err error) error { if err := q.Queue.ErrorAtOnce(c, ids, err); err != nil { return err } var errs []error for _, id := range ids { if deleteErr := q.store.TaskDelete(id); deleteErr != nil && !errors.Is(deleteErr, types.ErrRecordNotExist) { errs = append(errs, fmt.Errorf("task id [%s]: %w", id, deleteErr)) } } if len(errs) != 0 { return fmt.Errorf("failed to delete tasks from persistent store: %w", errors.Join(errs...)) } return nil } ================================================ FILE: server/queue/queue.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package queue import ( "context" "errors" "fmt" "strings" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) var ( // ErrCancel indicates the task was canceled. ErrCancel = errors.New("queue: task canceled") // ErrNotFound indicates the task was not found in the queue. ErrNotFound = errors.New("queue: task not found") // ErrAgentMissMatch indicates a task is assigned to a different agent. ErrAgentMissMatch = errors.New("task assigned to different agent") // ErrTaskExpired indicates a running task exceeded its lease/deadline and was resubmitted. ErrTaskExpired = errors.New("queue: task expired") // ErrWorkerKicked worker of an agent got kicked. ErrWorkerKicked = errors.New("worker was kicked") ) // ErrExternal wraps an external error. type ErrExternal struct { err error } func (e *ErrExternal) Error() string { return fmt.Sprintf("external error: %s", e.err) } // Unwrap allows errors.Is and errors.As to work with the wrapped error. func (e *ErrExternal) Unwrap() error { return e.err } // Is allows errors.Is to match against ErrExternal types. func (e *ErrExternal) Is(target error) bool { _, ok := target.(*ErrExternal) return ok } // NewErrExternal wraps an error as external one so queue can filter it out if needed. func NewErrExternal(err error) error { if err == nil { return nil } return &ErrExternal{err: err} } // InfoT provides runtime information. type InfoT struct { Pending []*model.Task `json:"pending"` WaitingOnDeps []*model.Task `json:"waiting_on_deps"` Running []*model.Task `json:"running"` Stats struct { Workers int `json:"worker_count"` Pending int `json:"pending_count"` WaitingOnDeps int `json:"waiting_on_deps_count"` Running int `json:"running_count"` } `json:"stats"` Paused bool `json:"paused"` } // @name InfoT func (t *InfoT) String() string { var sb strings.Builder for _, task := range t.Pending { sb.WriteString("\t" + task.String()) } for _, task := range t.Running { sb.WriteString("\t" + task.String()) } for _, task := range t.WaitingOnDeps { sb.WriteString("\t" + task.String()) } return sb.String() } // FilterFn filters tasks in the queue. If the Filter returns false, // the Task is skipped and not returned to the subscriber. // The int return value represents the matching score (higher is better). type FilterFn func(*model.Task) (bool, int) // Queue defines a task queue for scheduling tasks among // a pool of workers. type Queue interface { // PushAtOnce pushes multiple tasks to the tail of this queue. PushAtOnce(c context.Context, tasks []*model.Task) error // Poll retrieves and removes a task head of this queue. Poll(c context.Context, agentID int64, f FilterFn) (*model.Task, error) // Extend extends the deadline for a task. Extend(c context.Context, agentID int64, workflowID string) error // Done signals the task is complete. Done(c context.Context, id string, exitStatus model.StatusValue) error // Error signals the task is done with an error. Error(c context.Context, id string, err error) error // ErrorAtOnce signals multiple tasks are done and complete with an error. // If still pending they will just get removed from the queue. ErrorAtOnce(c context.Context, ids []string, err error) error // Wait waits until the task is complete. // Also signals via error ErrCancel if workflow got canceled. Wait(c context.Context, id string) error // Info returns internal queue information. Info(c context.Context) InfoT // Pause stops the queue from handing out new work items in Poll Pause() // Resume starts the queue again. Resume() // KickAgentWorkers kicks all workers for a given agent. KickAgentWorkers(agentID int64) } // Config holds the configuration for the queue. type Config struct { Backend Type Store store.Store } // Queue type. type Type string const ( TypeMemory Type = "memory" ) // New creates a new queue based on the provided configuration. func New(ctx context.Context, config Config) (Queue, error) { var q Queue switch config.Backend { case TypeMemory: q = NewMemoryQueue(ctx) if config.Store != nil { q = WithTaskStore(ctx, q, config.Store) } default: return nil, fmt.Errorf("unsupported queue backend: %s", config.Backend) } return q, nil } ================================================ FILE: server/router/api.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package router import ( "github.com/gin-gonic/gin" "github.com/rs/zerolog" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/api" "go.woodpecker-ci.org/woodpecker/v3/server/api/debug" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" ) func apiRoutes(e *gin.RouterGroup) { apiBase := e.Group("/api") { user := apiBase.Group("/user") { user.Use(session.MustUser()) user.GET("", api.GetSelf) user.GET("/feed", api.GetFeed) user.GET("/repos", api.GetRepos) user.POST("/token", api.PostToken) user.DELETE("/token", api.DeleteToken) } users := apiBase.Group("/users") { users.Use(session.MustAdmin()) users.GET("", api.GetUsers) users.POST("", api.PostUser) users.GET("/:login", api.GetUser) users.PATCH("/:login", api.PatchUser) users.DELETE("/:login", api.DeleteUser) } orgs := apiBase.Group("/orgs") { orgs.GET("", session.MustAdmin(), api.GetOrgs) orgs.GET("/lookup/*org_full_name", api.LookupOrg) orgBase := orgs.Group("/:org_id") { orgBase.Use(session.SetOrg()) orgBase.Use(session.MustOrg()) orgBase.GET("/permissions", api.GetOrgPermissions) orgBase.GET("", session.MustOrgMember(false), api.GetOrg) org := orgBase.Group("") { org.Use(session.MustOrgMember(true)) org.DELETE("", session.MustAdmin(), api.DeleteOrg) org.GET("/secrets", api.GetOrgSecretList) org.POST("/secrets", api.PostOrgSecret) org.GET("/secrets/:secret", api.GetOrgSecret) org.PATCH("/secrets/:secret", api.PatchOrgSecret) org.DELETE("/secrets/:secret", api.DeleteOrgSecret) org.GET("/registries", api.GetOrgRegistryList) org.POST("/registries", api.PostOrgRegistry) org.GET("/registries/:registry", api.GetOrgRegistry) org.PATCH("/registries/:registry", api.PatchOrgRegistry) org.DELETE("/registries/:registry", api.DeleteOrgRegistry) if !server.Config.Agent.DisableUserRegisteredAgentRegistration { org.GET("/agents", api.GetOrgAgents) org.POST("/agents", api.PostOrgAgent) org.PATCH("/agents/:agent_id", api.PatchOrgAgent) org.DELETE("/agents/:agent_id", api.DeleteOrgAgent) } } } } repo := apiBase.Group("/repos") { repo.GET("/lookup/*repo_full_name", session.SetRepo(), session.SetPerm(), session.MustPull, api.LookupRepo) repo.POST("", session.MustUser(), api.PostRepo) repo.GET("", session.MustAdmin(), api.GetAllRepos) repo.POST("/repair", session.MustAdmin(), api.RepairAllRepos) repoBase := repo.Group("/:repo_id") { repoBase.Use(session.SetRepo()) repoBase.Use(session.SetPerm()) repoBase.GET("/permissions", api.GetRepoPermissions) repo := repoBase.Group("") { repo.Use(session.MustPull) repo.GET("", api.GetRepo) repo.GET("/branches", api.GetRepoBranches) repo.GET("/pull_requests", api.GetRepoPullRequests) repo.GET("/pipelines", api.GetPipelines) repo.POST("/pipelines", session.MustPush, api.CreatePipeline) repo.DELETE("/pipelines/:pipeline_number", session.MustRepoAdmin(), api.DeletePipeline) repo.GET("/pipelines/:pipeline_number", api.GetPipeline) repo.GET("/pipelines/:pipeline_number/config", api.GetPipelineConfig) repo.GET("/pipelines/:pipeline_number/metadata", session.MustPush, api.GetPipelineMetadata) // requires push permissions repo.POST("/pipelines/:pipeline_number", session.MustPush, api.PostPipeline) repo.POST("/pipelines/:pipeline_number/cancel", session.MustPush, api.CancelPipeline) repo.POST("/pipelines/:pipeline_number/approve", session.MustPush, api.PostApproval) repo.POST("/pipelines/:pipeline_number/decline", session.MustPush, api.PostDecline) repo.GET("/logs/:pipeline_number/:step_id", api.GetStepLogs) repo.DELETE("/logs/:pipeline_number/:step_id", session.MustPush, api.DeleteStepLogs) // requires push permissions repo.DELETE("/logs/:pipeline_number", session.MustPush, api.DeletePipelineLogs) // requires push permissions repo.GET("/secrets", session.MustPush, api.GetSecretList) repo.POST("/secrets", session.MustPush, api.PostSecret) repo.GET("/secrets/:secret", session.MustPush, api.GetSecret) repo.PATCH("/secrets/:secret", session.MustPush, api.PatchSecret) repo.DELETE("/secrets/:secret", session.MustPush, api.DeleteSecret) // requires push permissions repo.GET("/registries", session.MustPush, api.GetRegistryList) repo.POST("/registries", session.MustPush, api.PostRegistry) repo.GET("/registries/:registry", session.MustPush, api.GetRegistry) repo.PATCH("/registries/:registry", session.MustPush, api.PatchRegistry) repo.DELETE("/registries/:registry", session.MustPush, api.DeleteRegistry) // requires push permissions repo.GET("/cron", session.MustPush, api.GetCronList) repo.POST("/cron", session.MustPush, api.PostCron) repo.GET("/cron/:cron", session.MustPush, api.GetCron) repo.POST("/cron/:cron", session.MustPush, api.RunCron) repo.PATCH("/cron/:cron", session.MustPush, api.PatchCron) repo.DELETE("/cron/:cron", session.MustPush, api.DeleteCron) // requires admin permissions repo.PATCH("", session.MustRepoAdmin(), api.PatchRepo) repo.DELETE("", session.MustRepoAdmin(), api.DeleteRepo) repo.POST("/chown", session.MustRepoAdmin(), api.ChownRepo) repo.POST("/repair", session.MustRepoAdmin(), api.RepairRepo) repo.POST("/move", session.MustRepoAdmin(), api.MoveRepo) } } } badges := apiBase.Group("/badges/:repo_id_or_owner") { badges.GET("/status.svg", api.GetBadge) badges.GET("/cc.xml", api.GetCC) } _badges := apiBase.Group("/badges/:repo_id_or_owner/:repo_name") { _badges.GET("/status.svg", api.GetBadge) _badges.GET("/cc.xml", api.GetCC) } pipelines := apiBase.Group("/pipelines") { pipelines.Use(session.MustAdmin()) pipelines.GET("", api.GetPipelineQueue) } queue := apiBase.Group("/queue") { queue.Use(session.MustAdmin()) queue.GET("/info", api.GetQueueInfo) queue.POST("/pause", api.PauseQueue) queue.POST("/resume", api.ResumeQueue) queue.GET("/norunningpipelines", api.BlockTilQueueHasRunningItem) } // global secrets can be read without actual values by any user readGlobalSecrets := apiBase.Group("/secrets") { readGlobalSecrets.Use(session.MustUser()) readGlobalSecrets.GET("", api.GetGlobalSecretList) readGlobalSecrets.GET("/:secret", api.GetGlobalSecret) } secrets := apiBase.Group("/secrets") { secrets.Use(session.MustAdmin()) secrets.POST("", api.PostGlobalSecret) secrets.PATCH("/:secret", api.PatchGlobalSecret) secrets.DELETE("/:secret", api.DeleteGlobalSecret) } // global registries can be read without actual values by any user readGlobalRegistries := apiBase.Group("/registries") { readGlobalRegistries.Use(session.MustUser()) readGlobalRegistries.GET("", api.GetGlobalRegistryList) readGlobalRegistries.GET("/:registry", api.GetGlobalRegistry) } registries := apiBase.Group("/registries") { registries.Use(session.MustAdmin()) registries.POST("", api.PostGlobalRegistry) registries.PATCH("/:registry", api.PatchGlobalRegistry) registries.DELETE("/:registry", api.DeleteGlobalRegistry) } logLevel := apiBase.Group("/log-level") { logLevel.Use(session.MustAdmin()) logLevel.GET("", api.LogLevel) logLevel.POST("", api.SetLogLevel) } agentBase := apiBase.Group("/agents") { agentBase.Use(session.MustAdmin()) agentBase.GET("", api.GetAgents) agentBase.POST("", api.PostAgent) agentBase.GET("/:agent_id", api.GetAgent) agentBase.GET("/:agent_id/tasks", api.GetAgentTasks) agentBase.PATCH("/:agent_id", api.PatchAgent) agentBase.DELETE("/:agent_id", api.DeleteAgent) } apiBase.GET("/forges", api.GetForges) apiBase.GET("/forges/:forge_id", api.GetForge) forgeBase := apiBase.Group("/forges") { forgeBase.Use(session.MustAdmin()) forgeBase.POST("", api.PostForge) forgeBase.PATCH("/:forge_id", api.PatchForge) forgeBase.DELETE("/:forge_id", api.DeleteForge) } apiBase.GET("/signature/public-key", api.GetSignaturePublicKey) apiBase.POST("/hook", api.PostHook) stream := apiBase.Group("/stream") { stream.GET("/logs/:repo_id/:pipeline/:step_id", session.SetRepo(), session.SetPerm(), session.MustPull, api.LogStreamSSE) stream.GET("/events", api.EventStreamSSE) } if zerolog.GlobalLevel() <= zerolog.DebugLevel { debugger := apiBase.Group("/debug") { debugger.Use(session.MustAdmin()) debugger.GET("/pprof/", debug.IndexHandler()) debugger.GET("/pprof/heap", debug.HeapHandler()) debugger.GET("/pprof/goroutine", debug.GoroutineHandler()) debugger.GET("/pprof/block", debug.BlockHandler()) debugger.GET("/pprof/threadcreate", debug.ThreadCreateHandler()) debugger.GET("/pprof/cmdline", debug.CmdlineHandler()) debugger.GET("/pprof/profile", debug.ProfileHandler()) debugger.GET("/pprof/symbol", debug.SymbolHandler()) debugger.POST("/pprof/symbol", debug.SymbolHandler()) debugger.GET("/pprof/trace", debug.TraceHandler()) } } } } ================================================ FILE: server/router/middleware/header/header.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package header import ( "net/http" "time" "github.com/gin-gonic/gin" ) // NoCache is a middleware function that appends headers // to prevent the client from caching the HTTP response. func NoCache(c *gin.Context) { c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value") c.Header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT") c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) c.Next() } // Options is a middleware function that appends headers // for options requests and aborts then exits the middleware // chain and ends the request. func Options(c *gin.Context) { if c.Request.Method != "OPTIONS" { c.Next() } else { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") c.Header("Access-Control-Allow-Headers", "authorization, origin, content-type, accept") c.Header("Allow", "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS") c.Header("Content-Type", "application/json") c.AbortWithStatus(http.StatusOK) } } // Secure is a middleware function that appends security // and resource access headers. func Secure(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("X-Frame-Options", "DENY") c.Header("X-Content-Type-Options", "nosniff") c.Header("X-XSS-Protection", "1; mode=block") if c.Request.TLS != nil { c.Header("Strict-Transport-Security", "max-age=31536000") } // Also consider adding Content-Security-Policy headers // c.Header("Content-Security-Policy", "script-src 'self' https://cdnjs.cloudflare.com") } ================================================ FILE: server/router/middleware/logger.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package middleware import ( "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" ) // Logger returns a gin.HandlerFunc (middleware) that logs requests using zerolog. // // Requests with errors are logged using log.Err(). // Requests without errors are logged using log.Info(). // // It receives: // 1. A time package format string (e.g. time.RFC3339). // 2. A boolean stating whether to use UTC time zone or local. func Logger(timeFormat string, utc bool) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() // some evil middlewares modify this values path := c.Request.URL.Path c.Next() end := time.Now() latency := end.Sub(start) if utc { end = end.UTC() } entry := map[string]any{ "status": c.Writer.Status(), "method": c.Request.Method, "path": path, "ip": c.ClientIP(), "latency": latency, "user-agent": c.Request.UserAgent(), "time": end.Format(timeFormat), } if len(c.Errors) > 0 { // Append error field if this is an erroneous request. log.Error().Str("error", c.Errors.String()).Fields(entry).Msg("") } else { log.Debug().Fields(entry).Msg("") } } } ================================================ FILE: server/router/middleware/session/agent.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package session import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) // AuthorizeAgent authorizes requests from agent to access the queue. func AuthorizeAgent(c *gin.Context) { secret, _ := c.MustGet("agent").(string) if secret == "" { c.String(http.StatusUnauthorized, "invalid or empty token.") return } _, err := token.ParseRequest([]token.Type{token.AgentToken}, c.Request, func(_ *token.Token) (string, error) { return secret, nil }) if err != nil { c.String(http.StatusInternalServerError, "invalid or empty token. %s", err) c.Abort() return } c.Next() } ================================================ FILE: server/router/middleware/session/org.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package session import ( "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func Org(c *gin.Context) *model.Org { v, ok := c.Get("org") if !ok { return nil } r, ok := v.(*model.Org) if !ok { return nil } return r } func SetOrg() gin.HandlerFunc { return func(c *gin.Context) { var ( orgID int64 err error ) orgParam := c.Param("org_id") if orgParam != "" { orgID, err = strconv.ParseInt(orgParam, 10, 64) if err != nil { c.String(http.StatusBadRequest, "Invalid organization ID") c.Abort() return } } org, err := store.FromContext(c).OrgGet(orgID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { _ = c.AbortWithError(http.StatusInternalServerError, err) return } if org == nil { c.String(http.StatusNotFound, "Organization not found") c.Abort() return } c.Set("org", org) c.Next() } } func MustOrg() gin.HandlerFunc { return func(c *gin.Context) { org := Org(c) switch org { case nil: c.String(http.StatusNotFound, "Organization not loaded") c.Abort() default: c.Next() } } } ================================================ FILE: server/router/middleware/session/pagination.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package session import ( "strconv" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const maxPageSize = 50 func Pagination(c *gin.Context) *model.ListOptions { page, err := strconv.ParseInt(c.Query("page"), 10, 64) if err != nil || page < 1 { page = 1 } perPage, err := strconv.ParseInt(c.Query("perPage"), 10, 64) if err != nil || perPage < 1 || perPage > maxPageSize { perPage = maxPageSize } return &model.ListOptions{ Page: int(page), PerPage: int(perPage), } } ================================================ FILE: server/router/middleware/session/repo.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package session import ( "errors" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func Repo(c *gin.Context) *model.Repo { v, ok := c.Get("repo") if !ok { return nil } r, ok := v.(*model.Repo) if !ok { return nil } r.Perm = Perm(c) return r } func SetRepo() gin.HandlerFunc { return func(c *gin.Context) { var ( _store = store.FromContext(c) fullName = strings.TrimLeft(c.Param("repo_full_name"), "/") _repoID = c.Param("repo_id") user = User(c) ) var repo *model.Repo var err error if _repoID != "" { var repoID int64 repoID, err = strconv.ParseInt(_repoID, 10, 64) if err != nil { c.AbortWithStatus(http.StatusBadRequest) return } repo, err = _store.GetRepo(repoID) } else { repo, err = _store.GetRepoName(fullName) } if repo != nil && err == nil { c.Set("repo", repo) c.Next() return } // debugging log.Debug().Err(err).Msgf("cannot find repository %s", fullName) if user == nil { c.AbortWithStatus(http.StatusUnauthorized) return } if errors.Is(err, types.ErrRecordNotExist) { c.AbortWithStatus(http.StatusNotFound) return } _ = c.AbortWithError(http.StatusInternalServerError, err) } } func Perm(c *gin.Context) *model.Perm { v, ok := c.Get("perm") if !ok { return nil } u, ok := v.(*model.Perm) if !ok { return nil } return u } func SetPerm() gin.HandlerFunc { return func(c *gin.Context) { _store := store.FromContext(c) user := User(c) repo := Repo(c) _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msg("Cannot get forge from repo") c.AbortWithStatus(http.StatusInternalServerError) return } perm := new(model.Perm) if user != nil { var err error perm, err = _store.PermFind(user, repo) if err != nil { log.Error().Err(err).Msgf("error fetching permission for %s %s", user.Login, repo.FullName) } if time.Unix(perm.Synced, 0).Add(time.Hour).Before(time.Now()) { _repo, err := _forge.Repo(c, user, repo.ForgeRemoteID, repo.Owner, repo.Name) if err == nil { log.Debug().Msgf("synced user permission for %s %s", user.Login, repo.FullName) _repo.ForgeID = user.ForgeID perm = _repo.Perm perm.RepoID = repo.ID perm.UserID = user.ID perm.Synced = time.Now().Unix() if err := _store.PermUpsert(perm); err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } } } } if perm == nil { perm = new(model.Perm) } if user != nil && user.Admin { perm.Pull = true perm.Push = true perm.Admin = true } if repo.Visibility == model.VisibilityPublic || (repo.Visibility == model.VisibilityInternal && user != nil) { perm.Pull = true } if user != nil { log.Debug().Msgf("%s granted %+v permission to %s", user.Login, perm, repo.FullName) } else { log.Debug().Msgf("guest granted %+v to %s", perm, repo.FullName) } c.Set("perm", perm) c.Next() } } func MustPull(c *gin.Context) { user := User(c) perm := Perm(c) if perm.Pull { c.Next() return } // debugging if user != nil { c.AbortWithStatus(http.StatusNotFound) log.Debug().Msgf("user %s denied read access to %s", user.Login, c.Request.URL.Path) } else { c.AbortWithStatus(http.StatusUnauthorized) log.Debug().Msgf("guest denied read access to %s %s", c.Request.Method, c.Request.URL.Path, ) } } func MustPush(c *gin.Context) { user := User(c) perm := Perm(c) // if the user has push access, immediately proceed // the middleware execution chain. if perm.Push { c.Next() return } // debugging if user != nil { c.AbortWithStatus(http.StatusNotFound) log.Debug().Msgf("user %s denied write access to %s", user.Login, c.Request.URL.Path) } else { c.AbortWithStatus(http.StatusUnauthorized) log.Debug().Msgf("guest denied write access to %s %s", c.Request.Method, c.Request.URL.Path, ) } } ================================================ FILE: server/router/middleware/session/user.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package session import ( "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) func User(c *gin.Context) *model.User { v, ok := c.Get("user") if !ok { return nil } u, ok := v.(*model.User) if !ok { return nil } return u } func SetUser() gin.HandlerFunc { return func(c *gin.Context) { var user *model.User t, err := token.ParseRequest([]token.Type{token.UserToken, token.SessToken}, c.Request, func(t *token.Token) (string, error) { var err error userID, err := strconv.ParseInt(t.Get("user-id"), 10, 64) if err != nil { return "", err } user, err = store.FromContext(c).GetUser(userID) return user.Hash, err }) if err == nil { c.Set("user", user) // if this is a session token (ie not the API token) // this means the user is accessing with a web browser, // so we should implement CSRF protection measures. if t.Type == token.SessToken { err = token.CheckCsrf(c.Request, func(_ *token.Token) (string, error) { return user.Hash, nil }) // if csrf token validation fails, exit immediately // with a not authorized error. if err != nil { c.AbortWithStatus(http.StatusUnauthorized) return } } } c.Next() } } func MustAdmin() gin.HandlerFunc { return func(c *gin.Context) { user := User(c) switch { case user == nil: c.String(http.StatusUnauthorized, "User not authorized") c.Abort() case !user.Admin: c.String(http.StatusForbidden, "User not authorized") c.Abort() default: c.Next() } } } func MustRepoAdmin() gin.HandlerFunc { return func(c *gin.Context) { user := User(c) perm := Perm(c) switch { case user == nil: c.String(http.StatusUnauthorized, "User not authorized") c.Abort() case !perm.Admin: c.String(http.StatusForbidden, "User not authorized") c.Abort() default: c.Next() } } } func MustUser() gin.HandlerFunc { return func(c *gin.Context) { user := User(c) switch user { case nil: c.String(http.StatusUnauthorized, "User not authorized") c.Abort() default: c.Next() } } } func MustOrgMember(admin bool) gin.HandlerFunc { return func(c *gin.Context) { user := User(c) if user == nil { c.String(http.StatusUnauthorized, "User not authorized") c.Abort() return } org := Org(c) if org == nil { c.String(http.StatusBadRequest, "Organization not loaded") c.Abort() return } // User can access his own, admin can access all if (org.Name == user.Login) || user.Admin { c.Next() return } _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { log.Error().Err(err).Msg("Cannot get forge from user") c.AbortWithStatus(http.StatusInternalServerError) return } perm, err := server.Config.Services.Membership.Get(c, _forge, user, org.Name) if err != nil { log.Error().Err(err).Msg("failed to check membership") c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) c.Abort() return } if perm == nil || (!admin && !perm.Member) || (admin && !perm.Admin) { c.String(http.StatusForbidden, "user not authorized") c.Abort() return } c.Next() } } ================================================ FILE: server/router/middleware/store.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package middleware import ( "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) // Store is a middleware function that initializes the Datastore and attaches to // the context of every http.Request. func Store(v store.Store) gin.HandlerFunc { return func(c *gin.Context) { store.ToContext(c, v) c.Next() } } ================================================ FILE: server/router/middleware/token/token.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package token import ( "net/http" "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) func Refresh(c *gin.Context) { user := session.User(c) if user != nil { _forge, err := server.Config.Services.Manager.ForgeFromUser(user) if err != nil { _ = c.AbortWithError(http.StatusInternalServerError, err) return } forge.Refresh(c, _forge, store.FromContext(c), user) } c.Next() } ================================================ FILE: server/router/middleware/version.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package middleware import ( "github.com/gin-gonic/gin" "go.woodpecker-ci.org/woodpecker/v3/version" ) // Version is a middleware function that appends the Woodpecker version information // to the HTTP response. This is intended for debugging and troubleshooting. func Version(c *gin.Context) { c.Header("X-WOODPECKER-VERSION", version.String()) } ================================================ FILE: server/router/router.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package router import ( "net/http" "net/url" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" swaggo_files "github.com/swaggo/files" swaggo_gin_swagger "github.com/swaggo/gin-swagger" "go.woodpecker-ci.org/woodpecker/v3/cmd/server/openapi" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/api" "go.woodpecker-ci.org/woodpecker/v3/server/api/metrics" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/header" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/token" "go.woodpecker-ci.org/woodpecker/v3/server/web" ) // Load loads the router. func Load(noRouteHandler http.HandlerFunc, middleware ...gin.HandlerFunc) http.Handler { e := gin.New() e.UseRawPath = true e.Use(gin.Recovery()) e.Use(func(c *gin.Context) { log.Trace().Msgf("[%s] %s", c.Request.Method, c.Request.URL.String()) c.Next() }) e.Use(header.NoCache) e.Use(header.Options) e.Use(header.Secure) e.Use(middleware...) e.Use(session.SetUser()) e.Use(token.Refresh) e.NoRoute(gin.WrapF(noRouteHandler)) base := e.Group(server.Config.Server.RootPath) { base.GET("/web-config.js", web.Config) base.GET("/logout", api.GetLogout) auth := base.Group("/authorize") { auth.GET("", api.HandleAuth) auth.POST("", api.HandleAuth) } base.GET("/metrics", metrics.PromHandler()) base.GET("/version", api.Version) base.GET("/healthz", api.Health) } apiRoutes(base) if server.Config.WebUI.EnableSwagger { setupSwaggerConfigAndRoutes(e) } return e } func setupSwaggerConfigAndRoutes(e *gin.Engine) { openapi.SwaggerInfo.Host = getHost(server.Config.Server.Host) openapi.SwaggerInfo.BasePath = server.Config.Server.RootPath + "/api" e.GET(server.Config.Server.RootPath+"/swagger/*any", swaggo_gin_swagger.WrapHandler(swaggo_files.Handler)) } func getHost(s string) string { parse, _ := url.Parse(s) return parse.Host } ================================================ FILE: server/rpc/auth_server.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "errors" "fmt" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) type WoodpeckerAuthServer struct { proto.UnimplementedWoodpeckerAuthServer jwtManager *JWTManager agentMasterToken string store store.Store } func NewWoodpeckerAuthServer(jwtManager *JWTManager, agentMasterToken string, store store.Store) *WoodpeckerAuthServer { return &WoodpeckerAuthServer{jwtManager: jwtManager, agentMasterToken: agentMasterToken, store: store} } func (s *WoodpeckerAuthServer) Auth(_ context.Context, req *proto.AuthRequest) (*proto.AuthResponse, error) { agent, err := s.getAgent(req.AgentId, req.AgentToken) if err != nil { return nil, fmt.Errorf("agent could not auth: %w", err) } accessToken, err := s.jwtManager.Generate(agent.ID) if err != nil { return nil, err } return &proto.AuthResponse{ Status: "ok", AgentId: agent.ID, AccessToken: accessToken, }, nil } func (s *WoodpeckerAuthServer) getAgent(agentID int64, agentToken string) (*model.Agent, error) { // global agent secret auth if s.agentMasterToken != "" { if agentToken == s.agentMasterToken && agentID == -1 { agent := &model.Agent{ OwnerID: model.IDNotSet, OrgID: model.IDNotSet, Token: s.agentMasterToken, Capacity: -1, } err := s.store.AgentCreate(agent) if err != nil { log.Error().Err(err).Msg("error creating system agent") return nil, err } return agent, nil } if agentToken == s.agentMasterToken { agent, err := s.store.AgentFind(agentID) if err != nil { if errors.Is(err, types.ErrRecordNotExist) { return nil, fmt.Errorf("AgentID not found in database") } else { return nil, err } } if !agent.IsSystemAgent() { return nil, fmt.Errorf("the agent with this ID is not a system agent") } return agent, nil } } // individual agent token auth agent, err := s.store.AgentFindByToken(agentToken) if err != nil && errors.Is(err, types.ErrRecordNotExist) { return nil, fmt.Errorf("individual agent not found by token: %w", err) } return agent, err } ================================================ FILE: server/rpc/auth_server_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" "go.woodpecker-ci.org/woodpecker/v3/server/model" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) // newAuthServer is a test helper that wires up a WoodpeckerAuthServer with the // given master token and a mock store, then returns both so tests can set // expectations before calling Auth / getAgent. func newAuthServer(t *testing.T, masterToken string, store *store_mocks.MockStore) *WoodpeckerAuthServer { t.Helper() jwtManager := NewJWTManager("test-secret") return NewWoodpeckerAuthServer(jwtManager, masterToken, store) } func TestAuth(t *testing.T) { t.Parallel() t.Run("master token with agentID=-1 creates new system agent and returns access token", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentCreate", &model.Agent{ OwnerID: model.IDNotSet, OrgID: model.IDNotSet, Token: "master-secret", Capacity: -1, }).Return(nil).Once() srv := newAuthServer(t, "master-secret", store) resp, err := srv.Auth(t.Context(), &proto.AuthRequest{ AgentId: -1, AgentToken: "master-secret", }) require.NoError(t, err) assert.Equal(t, "ok", resp.Status) assert.NotEmpty(t, resp.AccessToken) // The newly created agent has ID 0 (zero-value) because AgentCreate // doesn't set it in the mock – verify the token at least round-trips. claims, verifyErr := NewJWTManager("test-secret").Verify(resp.AccessToken) require.NoError(t, verifyErr) assert.Equal(t, resp.AgentId, claims.AgentID) }) t.Run("master token with existing agentID returns access token for that agent", func(t *testing.T) { t.Parallel() existingAgent := &model.Agent{ ID: 42, OrgID: model.IDNotSet, // system agent OwnerID: model.IDNotSet, } store := store_mocks.NewMockStore(t) store.On("AgentFind", int64(42)).Return(existingAgent, nil).Once() srv := newAuthServer(t, "master-secret", store) resp, err := srv.Auth(t.Context(), &proto.AuthRequest{ AgentId: 42, AgentToken: "master-secret", }) require.NoError(t, err) assert.Equal(t, "ok", resp.Status) assert.EqualValues(t, 42, resp.AgentId) assert.NotEmpty(t, resp.AccessToken) }) t.Run("individual agent token authenticates successfully", func(t *testing.T) { t.Parallel() agent := &model.Agent{ID: 7, Token: "individual-token"} store := store_mocks.NewMockStore(t) store.On("AgentFindByToken", "individual-token").Return(agent, nil).Once() // no master token configured srv := newAuthServer(t, "", store) resp, err := srv.Auth(t.Context(), &proto.AuthRequest{ AgentId: 0, AgentToken: "individual-token", }) require.NoError(t, err) assert.Equal(t, "ok", resp.Status) assert.EqualValues(t, 7, resp.AgentId) }) t.Run("bad token returns error", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentFindByToken", "wrong-token"). Return(nil, types.ErrRecordNotExist).Once() srv := newAuthServer(t, "", store) _, err := srv.Auth(t.Context(), &proto.AuthRequest{ AgentToken: "wrong-token", }) require.Error(t, err) assert.Contains(t, err.Error(), "agent could not auth") }) } func TestGetAgent(t *testing.T) { t.Parallel() t.Run("master token + agentID=-1 creates and returns a new system agent", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentCreate", &model.Agent{ OwnerID: model.IDNotSet, OrgID: model.IDNotSet, Token: "master", Capacity: -1, }).Return(nil).Once() srv := newAuthServer(t, "master", store) agent, err := srv.getAgent(-1, "master") require.NoError(t, err) require.NotNil(t, agent) assert.Equal(t, "master", agent.Token) assert.EqualValues(t, model.IDNotSet, agent.OrgID) }) t.Run("master token + agentID=-1 propagates AgentCreate error", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentCreate", &model.Agent{ OwnerID: model.IDNotSet, OrgID: model.IDNotSet, Token: "master", Capacity: -1, }).Return(errors.New("db error")).Once() srv := newAuthServer(t, "master", store) _, err := srv.getAgent(-1, "master") require.Error(t, err) assert.Contains(t, err.Error(), "db error") }) t.Run("master token + existing agentID returns the stored agent", func(t *testing.T) { t.Parallel() systemAgent := &model.Agent{ID: 99, OrgID: model.IDNotSet, OwnerID: model.IDNotSet} store := store_mocks.NewMockStore(t) store.On("AgentFind", int64(99)).Return(systemAgent, nil).Once() srv := newAuthServer(t, "master", store) agent, err := srv.getAgent(99, "master") require.NoError(t, err) assert.Equal(t, int64(99), agent.ID) }) t.Run("master token + agentID not found in database returns error", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentFind", int64(404)).Return(nil, types.ErrRecordNotExist).Once() srv := newAuthServer(t, "master", store) _, err := srv.getAgent(404, "master") require.Error(t, err) assert.Contains(t, err.Error(), "AgentID not found in database") }) t.Run("master token + agentID store returns unexpected error is propagated", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentFind", int64(1)).Return(nil, errors.New("connection reset")).Once() srv := newAuthServer(t, "master", store) _, err := srv.getAgent(1, "master") require.Error(t, err) assert.Contains(t, err.Error(), "connection reset") }) t.Run("master token + agentID that is not a system agent returns error", func(t *testing.T) { t.Parallel() // An agent with a non-IDNotSet OrgID is not a system agent. orgAgent := &model.Agent{ID: 5, OrgID: 100, OwnerID: model.IDNotSet} store := store_mocks.NewMockStore(t) store.On("AgentFind", int64(5)).Return(orgAgent, nil).Once() srv := newAuthServer(t, "master", store) _, err := srv.getAgent(5, "master") require.Error(t, err) assert.Contains(t, err.Error(), "not a system agent") }) t.Run("individual token auth succeeds when token is found", func(t *testing.T) { t.Parallel() agent := &model.Agent{ID: 3, Token: "ind-token"} store := store_mocks.NewMockStore(t) store.On("AgentFindByToken", "ind-token").Return(agent, nil).Once() // No master token set – falls straight to individual auth. srv := newAuthServer(t, "", store) got, err := srv.getAgent(0, "ind-token") require.NoError(t, err) assert.Equal(t, int64(3), got.ID) }) t.Run("individual token not found returns wrapped error", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentFindByToken", "bad-token"). Return(nil, types.ErrRecordNotExist).Once() srv := newAuthServer(t, "", store) _, err := srv.getAgent(0, "bad-token") require.Error(t, err) assert.Contains(t, err.Error(), "individual agent not found by token") }) t.Run("individual token store returns unexpected error is propagated", func(t *testing.T) { t.Parallel() store := store_mocks.NewMockStore(t) store.On("AgentFindByToken", "token"). Return(nil, errors.New("timeout")).Once() srv := newAuthServer(t, "", store) _, err := srv.getAgent(0, "token") require.Error(t, err) assert.Contains(t, err.Error(), "timeout") }) t.Run("master token configured but wrong token falls through to individual auth", func(t *testing.T) { t.Parallel() agent := &model.Agent{ID: 8, Token: "ind-token"} store := store_mocks.NewMockStore(t) // master token is "master" but caller sends "ind-token" → individual path store.On("AgentFindByToken", "ind-token").Return(agent, nil).Once() srv := newAuthServer(t, "master", store) got, err := srv.getAgent(0, "ind-token") require.NoError(t, err) assert.Equal(t, int64(8), got.ID) }) } ================================================ FILE: server/rpc/authorizer.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package grpc provides gRPC server implementation with JWT-based authentication. // // # Authentication Flow // // Uses a two-token approach: // // 1. Agent Token (long-lived): Configured via WOODPECKER_AGENT_SECRET, used only for initial Auth() call // 2. JWT Access Token (short-lived, 1 hour): Obtained from Auth(), included in metadata["token"] for all service calls // // # Interceptor Architecture // // Authorizer interceptors validate JWT tokens on every request: // 1. Extract JWT from metadata["token"] // 2. Verify signature and expiration // 3. Extract agent_id from JWT claims and store in context // // Auth endpoint (/proto.WoodpeckerAuth/Auth) bypasses validation to allow initial authentication. // // # Usage // // // Server setup // jwtManager := NewJWTManager(c.String("grpc-secret")) // authorizer := NewAuthorizer(jwtManager) // grpcServer := grpc.NewServer( // grpc.StreamInterceptor(authorizer.StreamInterceptor), // grpc.UnaryInterceptor(authorizer.UnaryInterceptor), // ) // // // Client usage // resp, _ := authClient.Auth(ctx, &proto.AuthRequest{AgentToken: "secret", AgentId: -1}) // ctx = metadata.AppendToOutgoingContext(ctx, "token", resp.AccessToken) // workflow, _ := woodpeckerClient.Next(ctx, &proto.NextRequest{...}) package rpc import ( "context" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) // StreamContextWrapper wraps gRPC ServerStream to allow context modification. type StreamContextWrapper interface { grpc.ServerStream SetContext(context.Context) } type wrapper struct { grpc.ServerStream ctx context.Context } func (w *wrapper) Context() context.Context { return w.ctx } func (w *wrapper) SetContext(ctx context.Context) { w.ctx = ctx } func newStreamContextWrapper(inner grpc.ServerStream) StreamContextWrapper { ctx := inner.Context() return &wrapper{ inner, ctx, } } // Authorizer validates JWT tokens and enriches context with agent information. type Authorizer struct { jwtManager *JWTManager } // NewAuthorizer creates a new JWT authorizer. func NewAuthorizer(jwtManager *JWTManager) *Authorizer { return &Authorizer{jwtManager: jwtManager} } // StreamInterceptor validates JWT tokens for streaming gRPC calls. func (a *Authorizer) StreamInterceptor(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { _stream := newStreamContextWrapper(stream) newCtx, err := a.authorize(stream.Context(), info.FullMethod) if err != nil { return err } _stream.SetContext(newCtx) return handler(srv, _stream) } // UnaryInterceptor validates JWT tokens for unary gRPC calls. func (a *Authorizer) UnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { newCtx, err := a.authorize(ctx, info.FullMethod) if err != nil { return nil, err } return handler(newCtx, req) } // authorize validates JWT and injects verified agent_id into the context. // Bypasses validation for /proto.WoodpeckerAuth/Auth endpoint. func (a *Authorizer) authorize(ctx context.Context, fullMethod string) (context.Context, error) { // bypass auth for token endpoint if fullMethod == "/proto.WoodpeckerAuth/Auth" { return ctx, nil } md, ok := metadata.FromIncomingContext(ctx) if !ok { return ctx, status.Errorf(codes.Unauthenticated, "metadata is not provided") } values := md["token"] if len(values) == 0 { return ctx, status.Errorf(codes.Unauthenticated, "token is not provided") } accessToken := values[0] claims, err := a.jwtManager.Verify(accessToken) if err != nil { return ctx, status.Errorf(codes.Unauthenticated, "access token is invalid: %v", err) } // inject agentID into context ctx = context.WithValue(ctx, agentIDKey, claims.AgentID) return ctx, nil } ================================================ FILE: server/rpc/authorizer_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) func newAuthorizer(t *testing.T) *Authorizer { t.Helper() return NewAuthorizer(NewJWTManager("auth-test-secret")) } // validTokenForAgent generates a JWT that the authorizer will accept. func validTokenForAgent(t *testing.T, agentID int64) string { t.Helper() token, err := NewJWTManager("auth-test-secret").Generate(agentID) require.NoError(t, err) return token } // ctxWithToken builds an incoming gRPC context carrying metadata["token"]. func ctxWithToken(ctx context.Context, token string) context.Context { return metadata.NewIncomingContext(ctx, metadata.Pairs("token", token)) } func TestAuthorize(t *testing.T) { t.Parallel() t.Run("Auth endpoint bypasses JWT validation", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) // Plain context with no metadata – would normally fail, but Auth is exempt. ctx, err := a.authorize(t.Context(), "/proto.WoodpeckerAuth/Auth") require.NoError(t, err) assert.NotNil(t, ctx) }) t.Run("missing metadata returns Unauthenticated", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) // A plain context has no gRPC incoming metadata. _, err := a.authorize(t.Context(), "/proto.WoodpeckerServer/Next") require.Error(t, err) s, ok := status.FromError(err) require.True(t, ok) assert.Equal(t, codes.Unauthenticated, s.Code()) assert.Contains(t, s.Message(), "metadata is not provided") }) t.Run("metadata present but token key absent returns Unauthenticated", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("other-key", "value")) _, err := a.authorize(ctx, "/proto.WoodpeckerServer/Next") require.Error(t, err) s, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, s.Code()) assert.Contains(t, s.Message(), "token is not provided") }) t.Run("invalid (garbage) token returns Unauthenticated", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) ctx := ctxWithToken(t.Context(), "this-is-not-a-jwt") _, err := a.authorize(ctx, "/proto.WoodpeckerServer/Next") require.Error(t, err) s, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, s.Code()) assert.Contains(t, s.Message(), "access token is invalid") }) t.Run("token signed with wrong secret returns Unauthenticated", func(t *testing.T) { t.Parallel() wrongManager := NewJWTManager("DIFFERENT-secret") token, err := wrongManager.Generate(55) require.NoError(t, err) a := newAuthorizer(t) // uses "auth-test-secret" ctx := ctxWithToken(t.Context(), token) _, err = a.authorize(ctx, "/proto.WoodpeckerServer/Next") require.Error(t, err) s, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, s.Code()) }) t.Run("valid token enriches context with agent_id metadata", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) token := validTokenForAgent(t, 77) ctx := ctxWithToken(t.Context(), token) newCtx, err := a.authorize(ctx, "/proto.WoodpeckerServer/Next") require.NoError(t, err) rawAgentID := newCtx.Value(agentIDKey) agentID, ok := rawAgentID.(int64) require.True(t, ok) assert.EqualValues(t, 77, agentID) }) t.Run("valid token preserves existing metadata keys", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) token := validTokenForAgent(t, 10) ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("token", token, "hostname", "worker-1"), ) newCtx, err := a.authorize(ctx, "/proto.WoodpeckerServer/Init") require.NoError(t, err) md, _ := metadata.FromIncomingContext(newCtx) assert.Equal(t, []string{"worker-1"}, md["hostname"]) agentID, _ := (newCtx.Value(agentIDKey)).(int64) assert.EqualValues(t, 10, agentID) }) t.Run("empty token value in metadata slice returns Unauthenticated", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) // Passing an empty string as the token value. ctx := ctxWithToken(t.Context(), "") _, err := a.authorize(ctx, "/proto.WoodpeckerServer/Next") require.Error(t, err) s, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, s.Code()) }) } func TestUnaryInterceptor(t *testing.T) { t.Parallel() t.Run("valid token calls handler with enriched context", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) token := validTokenForAgent(t, 21) ctx := ctxWithToken(t.Context(), token) var capturedCtx context.Context handler := func(ctx context.Context, _ any) (any, error) { capturedCtx = ctx return "ok", nil } resp, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{ FullMethod: "/proto.WoodpeckerServer/Next", }, handler) require.NoError(t, err) assert.Equal(t, "ok", resp) _, ok := metadata.FromIncomingContext(capturedCtx) require.True(t, ok) agentID, _ := (capturedCtx.Value(agentIDKey)).(int64) assert.EqualValues(t, 21, agentID) }) t.Run("invalid token does not call handler", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) ctx := ctxWithToken(t.Context(), "bad-token") handlerCalled := false handler := func(_ context.Context, _ any) (any, error) { handlerCalled = true return nil, nil } _, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{ FullMethod: "/proto.WoodpeckerServer/Next", }, handler) require.Error(t, err) assert.False(t, handlerCalled) }) t.Run("Auth endpoint bypasses token check and calls handler", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) // No token in context – fine because Auth is exempt. ctx := metadata.NewIncomingContext(t.Context(), metadata.MD{}) handlerCalled := false handler := func(_ context.Context, _ any) (any, error) { handlerCalled = true return nil, nil } _, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{ FullMethod: "/proto.WoodpeckerAuth/Auth", }, handler) require.NoError(t, err) assert.True(t, handlerCalled) }) t.Run("handler error is propagated", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) token := validTokenForAgent(t, 1) ctx := ctxWithToken(t.Context(), token) handler := func(_ context.Context, _ any) (any, error) { return nil, errors.New("handler boom") } _, err := a.UnaryInterceptor(ctx, nil, &grpc.UnaryServerInfo{ FullMethod: "/proto.WoodpeckerServer/Next", }, handler) require.Error(t, err) assert.Contains(t, err.Error(), "handler boom") }) } // mockServerStream is a minimal grpc.ServerStream for testing. type mockServerStream struct { ctx context.Context } func (m *mockServerStream) SetHeader(metadata.MD) error { return nil } func (m *mockServerStream) SendHeader(metadata.MD) error { return nil } func (m *mockServerStream) SetTrailer(metadata.MD) {} func (m *mockServerStream) Context() context.Context { return m.ctx } func (m *mockServerStream) SendMsg(any) error { return nil } func (m *mockServerStream) RecvMsg(any) error { return nil } func TestStreamInterceptor(t *testing.T) { t.Parallel() t.Run("valid token calls handler with enriched stream context", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) token := validTokenForAgent(t, 33) ctx := ctxWithToken(t.Context(), token) stream := &mockServerStream{ctx: ctx} var capturedStream grpc.ServerStream handler := func(_ any, s grpc.ServerStream) error { capturedStream = s return nil } err := a.StreamInterceptor(nil, stream, &grpc.StreamServerInfo{ FullMethod: "/proto.WoodpeckerServer/Next", }, handler) require.NoError(t, err) capturedCtx := capturedStream.Context() _, ok := metadata.FromIncomingContext(capturedCtx) require.True(t, ok) agentID, _ := (capturedCtx.Value(agentIDKey)).(int64) assert.EqualValues(t, 33, agentID) }) t.Run("invalid token does not call handler", func(t *testing.T) { t.Parallel() a := newAuthorizer(t) ctx := ctxWithToken(t.Context(), "garbage") stream := &mockServerStream{ctx: ctx} handlerCalled := false handler := func(_ any, _ grpc.ServerStream) error { handlerCalled = true return nil } err := a.StreamInterceptor(nil, stream, &grpc.StreamServerInfo{ FullMethod: "/proto.WoodpeckerServer/Next", }, handler) require.Error(t, err) assert.False(t, handlerCalled) s, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, s.Code()) }) t.Run("stream context wrapper SetContext and Context round-trip", func(t *testing.T) { t.Parallel() stream := &mockServerStream{ctx: t.Context()} wrapper := newStreamContextWrapper(stream) newCtx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("foo", "bar")) wrapper.SetContext(newCtx) md, ok := metadata.FromIncomingContext(wrapper.Context()) require.True(t, ok) assert.Equal(t, []string{"bar"}, md["foo"]) }) } ================================================ FILE: server/rpc/errors.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import "errors" var ( ErrAgentIllegalWorkflowReRunStateChange = errors.New("workflow was already marked as finished") ErrAgentIllegalWorkflowRun = errors.New("workflow is currently in blocked state") ErrAgentIllegalLogStreaming = errors.New("agent can not append logs to a step that is marked not running") ErrAgentIllegalRepo = errors.New("agent is not allowed to interact with repo") ) ================================================ FILE: server/rpc/filter.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "maps" "strings" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/queue" ) func createFilterFunc(agentFilter rpc.Filter) queue.FilterFn { return func(task *model.Task) (bool, int) { // Create a copy of the labels for filtering to avoid modifying the original task labels := maps.Clone(task.Labels) if requiredLabelsMissing(labels, agentFilter.Labels) { return false, 0 } // ignore internal labels for filtering for k := range labels { if strings.HasPrefix(k, pipeline.InternalLabelPrefix) { delete(labels, k) } } score := 0 for taskLabel, taskLabelValue := range labels { // if a task label is empty it will be ignored if taskLabelValue == "" { continue } // all task labels are required to be present for an agent to match agentLabelValue, ok := agentFilter.Labels[taskLabel] if !ok { // Check for required label agentLabelValue, ok = agentFilter.Labels["!"+taskLabel] if !ok { return false, 0 } } switch agentLabelValue { // if agent label has a wildcard case "*": score++ // if agent label has an exact match case taskLabelValue: score += 10 // agent doesn't match default: return false, 0 } } return true, score } } func requiredLabelsMissing(taskLabels, agentLabels map[string]string) bool { for label, value := range agentLabels { if len(label) > 0 && label[0] == '!' { val, ok := taskLabels[label[1:]] if !ok || val != value { return true } } } return false } ================================================ FILE: server/rpc/filter_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestCreateFilterFunc(t *testing.T) { tests := []struct { name string agentFilter rpc.Filter task *model.Task wantMatched bool wantScore int }{ { name: "Two exact matches", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "123", "platform": "linux"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "linux"}, }, wantMatched: true, wantScore: 20, }, { name: "Wildcard and exact match", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "*", "platform": "linux"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "linux"}, }, wantMatched: true, wantScore: 11, }, { name: "Partial match", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "123", "platform": "linux"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "windows"}, }, wantMatched: false, wantScore: 0, }, { name: "No match", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "456", "platform": "linux"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "windows"}, }, wantMatched: false, wantScore: 0, }, { name: "Missing required label on agent", agentFilter: rpc.Filter{ Labels: map[string]string{"platform": "linux"}, }, task: &model.Task{ Labels: map[string]string{"needed": "some"}, }, wantMatched: false, wantScore: 0, }, { name: "Empty task labels", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "123", "platform": "linux"}, }, task: &model.Task{ Labels: map[string]string{}, }, wantMatched: true, wantScore: 0, }, { name: "Agent with additional label", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "123", "platform": "linux", "extra": "value"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "linux", "empty": ""}, }, wantMatched: true, wantScore: 20, }, { name: "Two wildcard matches", agentFilter: rpc.Filter{ Labels: map[string]string{"org-id": "*", "platform": "*"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "linux"}, }, wantMatched: true, wantScore: 2, }, { name: "Required label matches without shebang", agentFilter: rpc.Filter{ Labels: map[string]string{"!org-id": "123", "platform": "linux", "extra": "value"}, }, task: &model.Task{ Labels: map[string]string{"org-id": "123", "platform": "linux", "empty": ""}, }, wantMatched: true, wantScore: 20, }, { name: "Two different labels", agentFilter: rpc.Filter{ Labels: map[string]string{"docker": "true"}, }, task: &model.Task{ Labels: map[string]string{"hello": "true"}, }, wantMatched: false, wantScore: 0, }, { name: "Exact match", agentFilter: rpc.Filter{ Labels: map[string]string{"docker": "true"}, }, task: &model.Task{ Labels: map[string]string{"docker": "true"}, }, wantMatched: true, wantScore: 10, }, { name: "Agent without labels", agentFilter: rpc.Filter{ Labels: map[string]string{}, }, task: &model.Task{ Labels: map[string]string{"docker": "true"}, }, wantMatched: false, wantScore: 0, }, { name: "Task without labels", agentFilter: rpc.Filter{ Labels: map[string]string{"docker": "true"}, }, task: &model.Task{ Labels: map[string]string{}, }, wantMatched: true, wantScore: 0, }, { name: "Agent and task without labels", agentFilter: rpc.Filter{ Labels: map[string]string{}, }, task: &model.Task{ Labels: map[string]string{}, }, wantMatched: true, wantScore: 0, }, { name: "Multiple matching labels", agentFilter: rpc.Filter{ Labels: map[string]string{"docker": "true", "shell": "true", "gpu": "true"}, }, task: &model.Task{ Labels: map[string]string{"docker": "true", "shell": "true", "gpu": "true"}, }, wantMatched: true, wantScore: 30, }, { name: "Additional label in agent", agentFilter: rpc.Filter{ Labels: map[string]string{"docker": "true", "shell": "true", "gpu": "true"}, }, task: &model.Task{ Labels: map[string]string{"docker": "true", "shell": "true"}, }, wantMatched: true, wantScore: 20, }, { name: "Additional label in task", agentFilter: rpc.Filter{ Labels: map[string]string{"docker": "true", "shell": "true"}, }, task: &model.Task{ Labels: map[string]string{"docker": "true", "shell": "true", "gpu": "true"}, }, wantMatched: false, wantScore: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { filterFunc := createFilterFunc(tt.agentFilter) gotMatched, gotScore := filterFunc(tt.task) assert.Equal(t, tt.wantMatched, gotMatched, "Matched result") assert.Equal(t, tt.wantScore, gotScore, "Score") }) } } func TestMissingRequiredLabels(t *testing.T) { t.Parallel() testdata := []struct { taskLabels map[string]string requiredLabels map[string]string want bool }{ // Required label present and matches { taskLabels: map[string]string{"os": "linux"}, requiredLabels: map[string]string{"!os": "linux", "platform": "arm64"}, want: false, }, // Required label present but does not match { taskLabels: map[string]string{"os": "windows"}, requiredLabels: map[string]string{"!os": "linux", "platform": "amd64"}, want: true, }, // Required label missing { taskLabels: map[string]string{"arch": "amd64"}, requiredLabels: map[string]string{"!os": "linux"}, want: true, }, // No agent labels { taskLabels: map[string]string{"os": "linux"}, requiredLabels: map[string]string{}, want: false, }, } for _, tt := range testdata { if got := requiredLabelsMissing(tt.taskLabels, tt.requiredLabels); got != tt.want { t.Errorf("requiredLabelsMissing(%v, %v) = %v, want %v", tt.taskLabels, tt.requiredLabels, got, tt.want) } } } ================================================ FILE: server/rpc/jwt_manager.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "errors" "fmt" "time" "github.com/golang-jwt/jwt/v5" ) // JWTManager is a JSON web token manager. type JWTManager struct { secretKey string tokenDuration time.Duration } // AgentTokenClaims is a custom JWT claims that contains an agent's information. type AgentTokenClaims struct { jwt.RegisteredClaims AgentID int64 `json:"agent_id"` } const jwtTokenDuration = 1 * time.Hour // NewJWTManager returns a new JWT manager. func NewJWTManager(secretKey string) *JWTManager { return &JWTManager{secretKey, jwtTokenDuration} } // Generate generates and signs a new token for a user. func (manager *JWTManager) Generate(agentID int64) (string, error) { claims := AgentTokenClaims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "woodpecker", Subject: fmt.Sprintf("%d", agentID), Audience: jwt.ClaimStrings{}, NotBefore: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()), ID: fmt.Sprintf("%d", agentID), ExpiresAt: jwt.NewNumericDate(time.Now().Add(manager.tokenDuration)), }, AgentID: agentID, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(manager.secretKey)) } // Verify verifies the access token string and return a user claim if the token is valid. func (manager *JWTManager) Verify(accessToken string) (*AgentTokenClaims, error) { token, err := jwt.ParseWithClaims( accessToken, &AgentTokenClaims{}, func(token *jwt.Token) (any, error) { _, ok := token.Method.(*jwt.SigningMethodHMAC) if !ok { return nil, errors.New("unexpected token signing method") } return []byte(manager.secretKey), nil }, ) if err != nil { return nil, fmt.Errorf("invalid token: %w", err) } claims, ok := token.Claims.(*AgentTokenClaims) if !ok { return nil, errors.New("invalid token claims") } return claims, nil } ================================================ FILE: server/rpc/jwt_manager_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "encoding/base64" "encoding/json" "fmt" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestJWTManager(t *testing.T) { t.Parallel() t.Run("generate and verify roundtrip", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") token, err := manager.Generate(42) require.NoError(t, err) assert.NotEmpty(t, token) claims, err := manager.Verify(token) require.NoError(t, err) assert.Equal(t, int64(42), claims.AgentID) }) t.Run("claims contain correct fields", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") token, err := manager.Generate(99) require.NoError(t, err) claims, err := manager.Verify(token) require.NoError(t, err) assert.Equal(t, int64(99), claims.AgentID) assert.Equal(t, "woodpecker", claims.Issuer) assert.Equal(t, fmt.Sprintf("%d", 99), claims.Subject) assert.Equal(t, fmt.Sprintf("%d", 99), claims.ID) }) t.Run("different agent IDs produce different tokens", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") token1, err := manager.Generate(1) require.NoError(t, err) token2, err := manager.Generate(2) require.NoError(t, err) assert.NotEqual(t, token1, token2) }) t.Run("expired token is rejected", func(t *testing.T) { t.Parallel() manager := &JWTManager{ secretKey: "test-secret", tokenDuration: 1 * time.Millisecond, } token, err := manager.Generate(42) require.NoError(t, err) time.Sleep(10 * time.Millisecond) _, err = manager.Verify(token) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid token") }) t.Run("wrong signing secret rejects token", func(t *testing.T) { t.Parallel() managerA := NewJWTManager("secret-A") managerB := NewJWTManager("secret-B") token, err := managerA.Generate(42) require.NoError(t, err) _, err = managerB.Verify(token) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid token") }) t.Run("tampered token is rejected", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") token, err := manager.Generate(42) require.NoError(t, err) // Flip a byte inside the decoded signature, then re-encode. parts := strings.Split(token, ".") require.Len(t, parts, 3) sig, err := base64.RawURLEncoding.DecodeString(parts[2]) require.NoError(t, err) sig[0] ^= 0xFF parts[2] = base64.RawURLEncoding.EncodeToString(sig) tampered := strings.Join(parts, ".") _, err = manager.Verify(tampered) assert.Error(t, err) }) t.Run("empty token is rejected", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") _, err := manager.Verify("") assert.Error(t, err) }) t.Run("garbage token is rejected", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") _, err := manager.Verify("this-is-not-a-jwt") assert.Error(t, err) }) t.Run("token generated with negative agent ID", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") token, err := manager.Generate(-1) require.NoError(t, err) claims, err := manager.Verify(token) require.NoError(t, err) assert.Equal(t, int64(-1), claims.AgentID) }) } // buildUnsignedToken manually constructs a JWT with alg=none so we can verify // that Verify() rejects it even though the signature section is empty. // We do NOT use the golang-jwt library here because modern versions refuse to // produce none-signed tokens — that is exactly the property we want to test. func buildUnsignedToken(t *testing.T, agentID int64) string { t.Helper() header := base64.RawURLEncoding.EncodeToString( jwtMustMarshal(t, map[string]string{"alg": "none", "typ": "JWT"}), ) payload := base64.RawURLEncoding.EncodeToString( jwtMustMarshal(t, map[string]any{ "agent_id": agentID, "iss": "woodpecker", }), ) // A none-signed JWT carries an empty signature segment. return header + "." + payload + "." } // buildRS256FakeToken constructs a JWT header claiming RS256 to exercise the // unexpected-signing-method guard inside JWTManager.Verify(). func buildRS256FakeToken(t *testing.T) string { t.Helper() header := base64.RawURLEncoding.EncodeToString( jwtMustMarshal(t, map[string]string{"alg": "RS256", "typ": "JWT"}), ) payload := base64.RawURLEncoding.EncodeToString( jwtMustMarshal(t, map[string]any{"agent_id": 1, "iss": "woodpecker"}), ) sig := base64.RawURLEncoding.EncodeToString([]byte("fake-rsa-sig")) return header + "." + payload + "." + sig } // buildFutureNbfToken constructs a JWT whose nbf claim is set far in the // future. The token must be rejected regardless of which check fires first. func buildFutureNbfToken(t *testing.T) string { t.Helper() const farFuture = int64(9_999_999_999) // year 2286 header := base64.RawURLEncoding.EncodeToString( jwtMustMarshal(t, map[string]string{"alg": "HS256", "typ": "JWT"}), ) payload := base64.RawURLEncoding.EncodeToString( jwtMustMarshal(t, map[string]any{ "agent_id": 1, "iss": "woodpecker", "nbf": farFuture, "exp": farFuture + 3600, }), ) badSig := base64.RawURLEncoding.EncodeToString([]byte("bad")) return header + "." + payload + "." + badSig } func jwtMustMarshal(t *testing.T, v any) []byte { t.Helper() b, err := json.Marshal(v) require.NoError(t, err) return b } func TestJWTManagerAdditional(t *testing.T) { t.Parallel() t.Run("none-algorithm token is rejected", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") noneToken := buildUnsignedToken(t, 42) // Sanity: token really does carry the none algorithm header. parts := strings.Split(noneToken, ".") require.Len(t, parts, 3) assert.Equal(t, "", parts[2], "signature part must be empty for none-alg tokens") _, err := manager.Verify(noneToken) assert.Error(t, err, "verifier must reject a none-algorithm token") assert.Contains(t, err.Error(), "invalid token") }) t.Run("RS256 token (unexpected signing method) is rejected", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") rs256Token := buildRS256FakeToken(t) _, err := manager.Verify(rs256Token) assert.Error(t, err, "verifier must reject tokens with an unexpected signing method") assert.Contains(t, err.Error(), "invalid token") }) t.Run("token with far-future NotBefore is rejected", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") futureToken := buildFutureNbfToken(t) _, err := manager.Verify(futureToken) assert.Error(t, err) }) t.Run("two valid tokens for same agent are each independently verifiable", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") tok1, err := manager.Generate(5) require.NoError(t, err) tok2, err := manager.Generate(5) require.NoError(t, err) claims1, err := manager.Verify(tok1) require.NoError(t, err) assert.Equal(t, int64(5), claims1.AgentID) claims2, err := manager.Verify(tok2) require.NoError(t, err) assert.Equal(t, int64(5), claims2.AgentID) }) t.Run("zero agent ID is preserved through generate/verify roundtrip", func(t *testing.T) { t.Parallel() manager := NewJWTManager("test-secret") token, err := manager.Generate(0) require.NoError(t, err) claims, err := manager.Verify(token) require.NoError(t, err) assert.Equal(t, int64(0), claims.AgentID) }) } ================================================ FILE: server/rpc/rpc.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2021 Informatyka Boguslawski sp. z o.o. sp.k., http://www.ib.pl/ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "encoding/json" "errors" "fmt" "maps" "strconv" "time" "github.com/oklog/ulid/v2" "github.com/prometheus/client_golang/prometheus" "github.com/rs/zerolog/log" grpc_metadata "google.golang.org/grpc/metadata" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" "go.woodpecker-ci.org/woodpecker/v3/server/queue" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type ctxKey struct{} // agentIDKey is a non imitable context key. var agentIDKey = &ctxKey{} // updateAgentLastWorkDelay the delay before the LastWork info should be updated. const updateAgentLastWorkDelay = time.Minute type RPC struct { scheduler scheduler.Scheduler logger logging.Log store store.Store pipelineTime *prometheus.GaugeVec pipelineCount *prometheus.CounterVec } // Next blocks until it provides the next workflow to execute. // TODO (6038): Server does not release waiting agents on graceful shutdown. func (s *RPC) Next(c context.Context, agentFilter rpc.Filter) (*rpc.Workflow, error) { if hostname, err := s.getHostnameFromContext(c); err == nil { log.Debug().Msgf("agent connected: %s: polling", hostname) } agent, err := s.getAgentFromContext(c) if err != nil { return nil, err } if agent.NoSchedule { time.Sleep(1 * time.Second) return nil, nil } agentServerLabels, err := agent.GetServerLabels() if err != nil { return nil, err } // enforce labels from server by overwriting agent labels maps.Copy(agentFilter.Labels, agentServerLabels) log.Trace().Msgf("Agent %s[%d] tries to pull task with labels: %v", agent.Name, agent.ID, agentFilter.Labels) filterFn := createFilterFunc(agentFilter) for { // poll blocks until a task is available or the context is canceled / worker is kicked task, err := s.scheduler.Poll(c, agent.ID, filterFn) if err != nil || task == nil { return nil, err } if task.ShouldRun() { workflow := new(rpc.Workflow) err = json.Unmarshal(task.Data, workflow) return workflow, err } // task should not run, so mark it as done if err := s.Done(c, task.ID, rpc.WorkflowState{}); err != nil { log.Error().Err(err).Msgf("marking workflow task '%s' as done failed", task.ID) } } } // Wait blocks until the workflow with the given ID is completed or got canceled. // Used to let agents wait for cancel signals from server side. func (s *RPC) Wait(c context.Context, workflowID string) (canceled bool, err error) { agent, err := s.getAgentFromContext(c) if err != nil { return false, err } if err := s.checkAgentPermissionByWorkflow(c, agent, workflowID, nil, nil); err != nil { return false, err } if err := s.scheduler.Wait(c, workflowID); err != nil { if errors.Is(err, queue.ErrCancel) { // we explicit send a cancel signal log.Debug().Str("workflowID", workflowID).Msg("while waiting the queue reported the workflow as canceled") return true, nil } // unknown error happened log.Error().Err(err).Str("workflowID", workflowID).Msg("while waiting the queue returned an unexpected error") return false, err } // workflow finished and on issues appeared log.Debug().Str("workflowID", workflowID).Msg("queue reported the workflow as finished") return false, nil } // Extend extends the lease for the workflow with the given ID. func (s *RPC) Extend(c context.Context, workflowID string) error { agent, err := s.getAgentFromContext(c) if err != nil { return err } err = s.updateAgentLastWork(agent) if err != nil { return err } if err := s.checkAgentPermissionByWorkflow(c, agent, workflowID, nil, nil); err != nil { return err } return s.scheduler.Extend(c, agent.ID, workflowID) } // Update let agent updates the step state at the server. func (s *RPC) Update(c context.Context, strWorkflowID string, state rpc.StepState) error { workflowID, err := strconv.ParseInt(strWorkflowID, 10, 64) if err != nil { return err } workflow, err := s.store.WorkflowLoad(workflowID) if err != nil { log.Error().Err(err).Msgf("rpc.update: cannot find workflow with id %d", workflowID) return err } currentPipeline, err := s.store.GetPipeline(workflow.PipelineID) if err != nil { log.Error().Err(err).Msgf("cannot find pipeline with id %d", workflow.PipelineID) return err } agent, err := s.getAgentFromContext(c) if err != nil { return err } step, err := s.store.StepByUUID(state.StepUUID) if err != nil { log.Error().Err(err).Msgf("cannot find step with uuid %s", state.StepUUID) return err } if step.PipelineID != currentPipeline.ID { msg := fmt.Sprintf("agent returned status with step uuid '%s' which does not belong to current pipeline", state.StepUUID) log.Error(). Int64("stepPipelineID", step.PipelineID). Int64("currentPipelineID", currentPipeline.ID). Msg(msg) return errors.New(msg) } repo, err := s.store.GetRepo(currentPipeline.RepoID) if err != nil { log.Error().Err(err).Msgf("cannot find repo with id %d", currentPipeline.RepoID) return err } // check before agent can alter some state if err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil { return err } // sanitize agent input: only allow step updates that the workflow state permits if err := checkWorkflowAllowsStepUpdate(workflow.State, step, state); err != nil { return err } if err := pipeline.UpdateStepStatus(c, s.store, step, state); err != nil { log.Error().Err(err).Msg("rpc.update: cannot update step") } if state.Exited { server.Config.Services.LogStore.StepFinished(step) } if currentPipeline.Workflows, err = s.store.WorkflowGetTree(currentPipeline); err != nil { log.Error().Err(err).Msg("cannot build tree from step list") return err } return s.notify(c, repo, currentPipeline) } // Init signals the workflow is initialized. func (s *RPC) Init(c context.Context, strWorkflowID string, state rpc.WorkflowState) error { workflowID, err := strconv.ParseInt(strWorkflowID, 10, 64) if err != nil { return err } workflow, err := s.store.WorkflowLoad(workflowID) if err != nil { log.Error().Err(err).Msgf("cannot find workflow with id %d", workflowID) return err } agent, err := s.getAgentFromContext(c) if err != nil { return err } workflow.AgentID = agent.ID currentPipeline, err := s.store.GetPipeline(workflow.PipelineID) if err != nil { log.Error().Err(err).Msgf("cannot find pipeline with id %d", workflow.PipelineID) return err } repo, err := s.store.GetRepo(currentPipeline.RepoID) if err != nil { log.Error().Err(err).Msgf("cannot find repo with id %d", currentPipeline.RepoID) return err } // check before agent can alter some state if err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil { return err } // check workflow's own state to prevent re-initializing a finished or blocked workflow if err := checkWorkflowState(workflow.State); err != nil { return err } if currentPipeline.Status == model.StatusPending { if currentPipeline, err = pipeline.UpdateToStatusRunning(s.store, *currentPipeline, state.Started); err != nil { log.Error().Err(err).Msgf("init: cannot update pipeline %d state", currentPipeline.ID) } } s.updateForgeStatus(c, repo, currentPipeline, workflow) defer func() { currentPipeline.Workflows, _ = s.store.WorkflowGetTree(currentPipeline) if err := s.notify(c, repo, currentPipeline); err != nil { log.Error().Err(err).Msg("could not publish pipeline state change to pubsub") } }() workflow, err = pipeline.UpdateWorkflowStatusToRunning(s.store, *workflow, state) if err != nil { return err } s.updateForgeStatus(c, repo, currentPipeline, workflow) return s.updateAgentLastWork(agent) } // Done marks the workflow with the given ID as stopped. func (s *RPC) Done(c context.Context, strWorkflowID string, state rpc.WorkflowState) error { workflowID, err := strconv.ParseInt(strWorkflowID, 10, 64) if err != nil { return err } workflow, err := s.store.WorkflowLoad(workflowID) if err != nil { log.Error().Err(err).Msgf("cannot find workflow with id %d", workflowID) return err } workflow.Children, err = s.store.StepListFromWorkflowFind(workflow) if err != nil { return err } currentPipeline, err := s.store.GetPipeline(workflow.PipelineID) if err != nil { log.Error().Err(err).Msgf("cannot find pipeline with id %d", workflow.PipelineID) return err } repo, err := s.store.GetRepo(currentPipeline.RepoID) if err != nil { log.Error().Err(err).Msgf("cannot find repo with id %d", currentPipeline.RepoID) return err } agent, err := s.getAgentFromContext(c) if err != nil { return err } // check before agent can alter some state if err := s.checkAgentPermissionByWorkflow(c, agent, strWorkflowID, currentPipeline, repo); err != nil { return err } // check workflow's own state to prevent finishing an already-finished or blocked workflow if err := checkWorkflowState(workflow.State); err != nil { return err } logger := log.With(). Str("repo_id", fmt.Sprint(repo.ID)). Str("pipeline_id", fmt.Sprint(currentPipeline.ID)). Str("workflow_id", strWorkflowID).Logger() logger.Debug().Msgf("workflow state in store: %#v", workflow) logger.Debug().Msgf("gRPC Done with state: %#v", state) // Complete any still-running children (e.g. service containers) before // computing the workflow status, so their final state is reflected. s.completeChildrenIfParentCompleted(workflow, state.Finished) if workflow, err = pipeline.UpdateWorkflowStatusToDone(s.store, *workflow, state); err != nil { logger.Error().Err(err).Msgf("pipeline.UpdateWorkflowStatusToDone: cannot update workflow state: %s", err) } var queueErr error if !state.Canceled { if workflow.Failing() { queueErr = s.scheduler.Error(c, strWorkflowID, fmt.Errorf("workflow finished with error %s", state.Error)) } else { queueErr = s.scheduler.Done(c, strWorkflowID, workflow.State) } } else { if workflow.Started > 0 { queueErr = s.scheduler.Done(c, strWorkflowID, model.StatusKilled) } else { queueErr = s.scheduler.Done(c, strWorkflowID, model.StatusCanceled) } } if queueErr != nil { logger.Error().Err(queueErr).Msg("queue.Done: cannot ack workflow") } currentPipeline.Workflows, err = s.store.WorkflowGetTree(currentPipeline) if err != nil { return err } if !model.IsThereRunningStage(currentPipeline.Workflows) { if currentPipeline, err = pipeline.UpdateStatusToDone(s.store, *currentPipeline, pipeline.PipelineStatus(currentPipeline.Workflows), workflow.Finished); err != nil { logger.Error().Err(err).Msgf("pipeline.UpdateStatusToDone: cannot update workflows final state") } } s.updateForgeStatus(c, repo, currentPipeline, workflow) // make sure writes to pubsub are non blocking (https://github.com/woodpecker-ci/woodpecker/blob/c919f32e0b6432a95e1a6d3d0ad662f591adf73f/server/logging/log.go#L9) go func() { for _, step := range workflow.Children { if step.State != model.StatusSkipped { if err := s.logger.Close(c, step.ID); err != nil { logger.Error().Err(err).Msgf("done: cannot close log stream for step %d", step.ID) } } } }() if err := s.notify(c, repo, currentPipeline); err != nil { return err } if currentPipeline.Status == model.StatusSuccess || currentPipeline.Status == model.StatusFailure { s.pipelineCount.WithLabelValues(repo.FullName, currentPipeline.Branch, string(currentPipeline.Status), "total").Inc() s.pipelineTime.WithLabelValues(repo.FullName, currentPipeline.Branch, string(currentPipeline.Status), "total").Set(float64(currentPipeline.Finished - currentPipeline.Started)) } if currentPipeline.IsMultiPipeline() { s.pipelineTime.WithLabelValues(repo.FullName, currentPipeline.Branch, string(workflow.State), workflow.Name).Set(float64(workflow.Finished - workflow.Started)) } return s.updateAgentLastWork(agent) } // Log writes a log entry to the database and publishes it to the pubsub. // An explicit stepUUID makes it obvious that all entries must come from the same step. func (s *RPC) Log(c context.Context, stepUUID string, rpcLogEntries []*rpc.LogEntry) error { step, err := s.store.StepByUUID(stepUUID) if err != nil { return fmt.Errorf("could not find step with uuid %s in store: %w", stepUUID, err) } agent, err := s.getAgentFromContext(c) if err != nil { return err } currentPipeline, err := s.store.GetPipeline(step.PipelineID) if err != nil { log.Error().Err(err).Msgf("cannot find pipeline with id %d", step.PipelineID) return err } // check before agent can alter some state if err := s.checkAgentPermissionByWorkflow(c, agent, "", currentPipeline, nil); err != nil { return err } // sanitize agent input if err := allowAppendingLogs(currentPipeline, step); err != nil { return fmt.Errorf("can not alter logs: %w", err) } err = s.updateAgentLastWork(agent) if err != nil { return err } var logEntries []*model.LogEntry for _, rpcLogEntry := range rpcLogEntries { if rpcLogEntry.StepUUID != stepUUID { return fmt.Errorf("expected step UUID %s, got %s", stepUUID, rpcLogEntry.StepUUID) } logEntries = append(logEntries, &model.LogEntry{ StepID: step.ID, Time: rpcLogEntry.Time, Line: rpcLogEntry.Line, Data: rpcLogEntry.Data, Type: model.LogEntryType(rpcLogEntry.Type), }) } // make sure writes to pubsub are non blocking (https://github.com/woodpecker-ci/woodpecker/blob/c919f32e0b6432a95e1a6d3d0ad662f591adf73f/server/logging/log.go#L9) go func() { // write line to listening web clients if err := s.logger.Write(c, step.ID, logEntries); err != nil { log.Error().Err(err).Msgf("rpc server could not write to logger") } }() if err = server.Config.Services.LogStore.LogAppend(step, logEntries); err != nil { log.Error().Err(err).Msg("could not store log entries") } return nil } func (s *RPC) RegisterAgent(ctx context.Context, info rpc.AgentInfo) (int64, error) { agent, err := s.getAgentFromContext(ctx) if err != nil { return -1, err } if agent.Name == "" { if hostname, err := s.getHostnameFromContext(ctx); err == nil { agent.Name = hostname } } agent.Backend = info.Backend agent.Platform = info.Platform agent.Capacity = int32(info.Capacity) agent.Version = info.Version agent.CustomLabels = info.CustomLabels err = s.store.AgentUpdate(agent) if err != nil { return -1, err } return agent.ID, nil } // UnregisterAgent removes the agent from the database. func (s *RPC) UnregisterAgent(ctx context.Context) error { agent, err := s.getAgentFromContext(ctx) if !agent.IsSystemAgent() { // registered with individual agent token -> do not unregister return nil } log.Debug().Msgf("un-registering agent with ID %d", agent.ID) if err != nil { return err } err = s.store.AgentDelete(agent) return err } func (s *RPC) ReportHealth(ctx context.Context, status string) error { agent, err := s.getAgentFromContext(ctx) if err != nil { return err } if status != "I am alive!" { //nolint:staticcheck return errors.New("Are you alive?") } agent.LastContact = time.Now().Unix() return s.store.AgentUpdate(agent) } func (s *RPC) completeChildrenIfParentCompleted(completedWorkflow *model.Workflow, finished int64) { for _, c := range completedWorkflow.Children { if c.Running() { if updated, err := pipeline.UpdateStepToStatusSkipped(s.store, *c, finished, model.StatusKilled); err != nil { log.Error().Err(err).Msgf("done: cannot update step_id %d child state", c.ID) } else { // Update in-memory state so WorkflowStatus sees the final state c.State = updated.State c.Finished = updated.Finished } } } } func (s *RPC) updateForgeStatus(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) { user, err := s.store.GetUser(repo.UserID) if err != nil { log.Error().Err(err).Msgf("cannot get user with id '%d'", repo.UserID) return } _forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) if err != nil { log.Error().Err(err).Msgf("can not get forge for repo '%s'", repo.FullName) return } forge.Refresh(ctx, _forge, s.store, user) // only do status updates for parent steps if workflow != nil { err = _forge.Status(ctx, user, repo, pipeline, workflow) if err != nil { log.Error().Err(err).Msgf("error setting commit status for %s/%d", repo.FullName, pipeline.Number) } } } // Notify push to our pubsub infra pipeline state changes. func (s *RPC) notify(c context.Context, repo *model.Repo, pipeline *model.Pipeline) (err error) { message := pubsub.Message{ID: ulid.Make().String()} message.Data, err = json.Marshal(model.Event{ Repo: *repo, Pipeline: *pipeline, }) if err != nil { return fmt.Errorf("can't marshal JSON: %w", err) } subTopics := make(map[string]struct{}) // if repo is public, push to public topic if !repo.IsSCMPrivate { subTopics[pubsub.PublicTopic] = struct{}{} } // publish to repo specific topic subTopics[pubsub.GetRepoTopic(repo)] = struct{}{} return s.scheduler.Publish(c, subTopics, message) } func (s *RPC) getAgentFromContext(ctx context.Context) (*model.Agent, error) { rawAgentID := ctx.Value(agentIDKey) if rawAgentID == nil { return nil, errors.New("agent_id is not provided") } agentID, ok := rawAgentID.(int64) if !ok { return nil, errors.New("agent_id is not a valid integer") } return s.store.AgentFind(agentID) } func (s *RPC) getHostnameFromContext(ctx context.Context) (string, error) { metadata, ok := grpc_metadata.FromIncomingContext(ctx) if ok { hostname, ok := metadata["hostname"] if ok && len(hostname) != 0 { return hostname[0], nil } } return "", errors.New("no hostname in metadata") } func (s *RPC) updateAgentLastWork(agent *model.Agent) error { // only update agent.LastWork if not recently updated if time.Unix(agent.LastWork, 0).Add(updateAgentLastWorkDelay).After(time.Now()) { return nil } agent.LastWork = time.Now().Unix() if err := s.store.AgentUpdate(agent); err != nil { return err } return nil } ================================================ FILE: server/rpc/rpc_integration_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "errors" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub/memory" "go.woodpecker-ci.org/woodpecker/v3/server/queue" queue_mocks "go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" log_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/log/mocks" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) // newTestRPC creates an RPC instance with common test infrastructure. func newTestRPC(t *testing.T, mockStore *store_mocks.MockStore, q queue.Queue) RPC { t.Helper() pipelineTime := prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "woodpecker_test", Name: "pipeline_time_" + t.Name(), }, []string{"repo", "branch", "status", "pipeline"}) pipelineCount := prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: "woodpecker_test", Name: "pipeline_count_" + t.Name(), }, []string{"repo", "branch", "status", "pipeline"}) return RPC{ store: mockStore, scheduler: scheduler.NewScheduler(q, memory.New()), logger: logging.New(), pipelineTime: pipelineTime, pipelineCount: pipelineCount, } } // defaultAgent returns a system agent (OrgID=-1) that can access any repo. func defaultAgent() *model.Agent { return &model.Agent{ ID: 1, Name: "test-agent", OrgID: model.IDNotSet, } } // orgAgent999 returns an agent scoped to a specific org. func orgAgent999() *model.Agent { return &model.Agent{ ID: 2, Name: "org-agent", OrgID: 999, } } func defaultRepo() *model.Repo { return &model.Repo{ ID: 10, OrgID: 100, FullName: "test-org/test-repo", } } func defaultPipeline(status model.StatusValue) *model.Pipeline { return &model.Pipeline{ ID: 20, RepoID: 10, Status: status, Branch: "main", } } func defaultWorkflow(state model.StatusValue) *model.Workflow { return &model.Workflow{ ID: 30, PipelineID: 20, State: state, Name: "test-workflow", } } func defaultStep(state model.StatusValue) *model.Step { return &model.Step{ ID: 40, UUID: "step-uuid-123", PipelineID: 20, State: state, } } func TestRPCUpdate(t *testing.T) { t.Run("happy path", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() repo := defaultRepo() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) step := defaultStep(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("GetRepo", int64(10)).Return(repo, nil) // pipeline.UpdateStepStatus calls StepUpdate mockStore.On("StepUpdate", mock.Anything).Return(nil) mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{workflow}, nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Update(ctx, "30", rpc.StepState{ StepUUID: "step-uuid-123", Started: 100, Exited: false, }) assert.NoError(t, err) }) t.Run("allow terminal step update when workflow already finished", func(t *testing.T) { // When the workflow is already finished, a step update that moves the // step to a terminal state (e.g. reporting exit code) should be allowed. mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusSuccess) // finished step := defaultStep(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("StepUpdate", mock.Anything).Return(nil) mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{workflow}, nil) mockLogStore.On("StepFinished", mock.Anything).Return() rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) // Step reports exit → it will transition to success/failure (terminal) err := rpcInst.Update(ctx, "30", rpc.StepState{ StepUUID: "step-uuid-123", Exited: true, ExitCode: 0, }) assert.NoError(t, err) }) t.Run("reject non-terminal step update when workflow already finished", func(t *testing.T) { // When the workflow is already finished, a step update that would keep // the step in a non-terminal state (e.g. just started, no exit) is rejected. mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusSuccess) // finished step := defaultStep(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) // Step reports started but not exited → still running (non-terminal) err := rpcInst.Update(ctx, "30", rpc.StepState{StepUUID: "step-uuid-123"}) assert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("reject step update when workflow blocked", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusBlocked) workflow := defaultWorkflow(model.StatusBlocked) step := defaultStep(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Update(ctx, "30", rpc.StepState{StepUUID: "step-uuid-123"}) assert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("reject step belongs to different pipeline", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) step := &model.Step{ ID: 40, UUID: "step-uuid-123", PipelineID: 999, // different pipeline! State: model.StatusRunning, } mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Update(ctx, "30", rpc.StepState{StepUUID: "step-uuid-123"}) require.Error(t, err) assert.Contains(t, err.Error(), "does not belong to current pipeline") }) t.Run("reject agent from wrong org", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := orgAgent999() repo := defaultRepo() // org 100 pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) step := defaultStep(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(2)).Return(agent, nil) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("GetRepo", int64(10)).Return(repo, nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(2)) err := rpcInst.Update(ctx, "30", rpc.StepState{StepUUID: "step-uuid-123"}) require.Error(t, err) assert.Contains(t, err.Error(), "not allowed to interact") }) t.Run("reject invalid workflow ID", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Update(ctx, "not-a-number", rpc.StepState{StepUUID: "step-uuid-123"}) assert.Error(t, err) }) t.Run("reject nonexistent workflow", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("WorkflowLoad", int64(999)).Return(nil, errors.New("not found")) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Update(ctx, "999", rpc.StepState{StepUUID: "step-uuid-123"}) assert.Error(t, err) }) t.Run("reject nonexistent step UUID", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("StepByUUID", "nonexistent").Return(nil, errors.New("not found")) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Update(ctx, "30", rpc.StepState{StepUUID: "nonexistent"}) assert.Error(t, err) }) t.Run("reject missing agent metadata", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) rpcInst := newTestRPC(t, mockStore, nil) // no agent_id in metadata ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs()) err := rpcInst.Update(ctx, "30", rpc.StepState{StepUUID: "step-uuid-123"}) assert.Error(t, err) }) } func TestRPCInit(t *testing.T) { t.Run("happy path - pending pipeline", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() repo := defaultRepo() pipeline := defaultPipeline(model.StatusPending) workflow := defaultWorkflow(model.StatusPending) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(repo, nil) // pipeline.UpdateToStatusRunning -> UpdatePipeline mockStore.On("UpdatePipeline", mock.Anything).Return(nil) // updateForgeStatus -> GetUser returns error so forge interaction is skipped mockStore.On("GetUser", mock.Anything).Return(nil, errors.New("user not found")) // pipeline.UpdateWorkflowStatusToRunning -> WorkflowUpdate mockStore.On("WorkflowUpdate", mock.Anything).Return(nil) // pubsub deferred -> WorkflowGetTree mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{workflow}, nil) // updateAgentLastWork -> AgentUpdate mockStore.On("AgentUpdate", mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Init(ctx, "30", rpc.WorkflowState{Started: 100}) assert.NoError(t, err) }) t.Run("happy path - already running pipeline", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() repo := defaultRepo() pipeline := defaultPipeline(model.StatusRunning) // another workflow already started it workflow := defaultWorkflow(model.StatusPending) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(repo, nil) // updateForgeStatus -> GetUser returns error so forge interaction is skipped mockStore.On("GetUser", mock.Anything).Return(nil, errors.New("user not found")) mockStore.On("WorkflowUpdate", mock.Anything).Return(nil) mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{workflow}, nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Init(ctx, "30", rpc.WorkflowState{Started: 100}) assert.NoError(t, err) }) t.Run("reject workflow already finished", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusSuccess) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Init(ctx, "30", rpc.WorkflowState{Started: 100}) assert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("reject workflow blocked", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusBlocked) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Init(ctx, "30", rpc.WorkflowState{Started: 100}) assert.ErrorIs(t, err, ErrAgentIllegalWorkflowRun) }) t.Run("reject agent wrong org", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := orgAgent999() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusPending) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("AgentFind", int64(2)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(2)) err := rpcInst.Init(ctx, "30", rpc.WorkflowState{Started: 100}) require.Error(t, err) assert.Contains(t, err.Error(), "not allowed to interact") }) t.Run("reject invalid workflow ID", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Init(ctx, "not-a-number", rpc.WorkflowState{}) assert.Error(t, err) }) } func TestRPCDone(t *testing.T) { t.Run("happy path", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockQueue := queue_mocks.NewMockQueue(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() repo := defaultRepo() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) workflow.Children = []*model.Step{} mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("StepListFromWorkflowFind", mock.Anything).Return([]*model.Step{}, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(repo, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("WorkflowUpdate", mock.Anything).Return(nil) mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{}, nil) mockStore.On("UpdatePipeline", mock.Anything).Return(nil) mockStore.On("GetUser", mock.Anything).Return(nil, errors.New("user not found")) mockStore.On("AgentUpdate", mock.Anything).Return(nil) mockQueue.On("Done", mock.Anything, mock.Anything, mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, mockQueue) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Done(ctx, "30", rpc.WorkflowState{Started: 100, Finished: 200}) assert.NoError(t, err) }) t.Run("reject workflow already finished", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusSuccess) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("StepListFromWorkflowFind", mock.Anything).Return([]*model.Step{}, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Done(ctx, "30", rpc.WorkflowState{Finished: 200}) assert.ErrorIs(t, err, ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("reject workflow blocked", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusBlocked) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("StepListFromWorkflowFind", mock.Anything).Return([]*model.Step{}, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Done(ctx, "30", rpc.WorkflowState{Finished: 200}) assert.ErrorIs(t, err, ErrAgentIllegalWorkflowRun) }) t.Run("reject agent wrong org", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := orgAgent999() pipeline := defaultPipeline(model.StatusRunning) workflow := defaultWorkflow(model.StatusRunning) mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("StepListFromWorkflowFind", mock.Anything).Return([]*model.Step{}, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentFind", int64(2)).Return(agent, nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(2)) err := rpcInst.Done(ctx, "30", rpc.WorkflowState{Finished: 200}) require.Error(t, err) assert.Contains(t, err.Error(), "not allowed to interact") }) t.Run("reject invalid workflow ID", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Done(ctx, "invalid", rpc.WorkflowState{}) assert.Error(t, err) }) } func TestRPCLog(t *testing.T) { // helper: a pipeline whose Finished timestamp is far enough in the past // that it is outside the drain window, so log appending is rejected. stalePipeline := func(status model.StatusValue) *model.Pipeline { p := defaultPipeline(status) p.Finished = time.Now().Add(-(logStreamDelayAllowed + time.Minute)).Unix() return p } // helper: a pipeline that finished very recently (within drain window). recentPipeline := func(status model.StatusValue) *model.Pipeline { p := defaultPipeline(status) p.Finished = time.Now().Add(-30 * time.Second).Unix() return p } t.Run("happy path: step running, pipeline running", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) step := defaultStep(model.StatusRunning) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) mockLogStore.On("LogAppend", mock.Anything, mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) entries := []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Line: 0, Data: []byte("hello")}, {StepUUID: "step-uuid-123", Line: 1, Data: []byte("world")}, } err := rpcInst.Log(ctx, "step-uuid-123", entries) assert.NoError(t, err) }) t.Run("allow: step finished but pipeline still running (logs draining)", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) // pipeline still running step := defaultStep(model.StatusSuccess) // but step already finished mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) mockLogStore.On("LogAppend", mock.Anything, mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("late log")}, }) assert.NoError(t, err) }) t.Run("allow: step running even though pipeline finished stale (step takes priority)", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() pipeline := stalePipeline(model.StatusSuccess) // finished long ago step := defaultStep(model.StatusRunning) // but step is still running mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) mockLogStore.On("LogAppend", mock.Anything, mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("running log")}, }) assert.NoError(t, err) }) t.Run("allow: pipeline finished recently — within drain window", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() pipeline := recentPipeline(model.StatusSuccess) // finished 30s ago step := defaultStep(model.StatusSuccess) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) mockLogStore.On("LogAppend", mock.Anything, mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("drain log")}, }) assert.NoError(t, err) }) t.Run("reject: pipeline finished stale and step not running", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := stalePipeline(model.StatusSuccess) step := defaultStep(model.StatusSuccess) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("test")}, }) require.Error(t, err) assert.Contains(t, err.Error(), "can not alter logs") assert.ErrorIs(t, err, ErrAgentIllegalLogStreaming) }) t.Run("reject: pipeline failed stale and step not running", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := stalePipeline(model.StatusFailure) step := defaultStep(model.StatusFailure) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("test")}, }) require.Error(t, err) assert.ErrorIs(t, err, ErrAgentIllegalLogStreaming) }) t.Run("reject: step pending (not running), pipeline not running, outside drain window", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := stalePipeline(model.StatusKilled) step := defaultStep(model.StatusPending) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("test")}, }) require.Error(t, err) assert.Contains(t, err.Error(), "can not alter logs") assert.ErrorIs(t, err, ErrAgentIllegalLogStreaming) }) t.Run("reject: step already succeeded, pipeline succeeded stale", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := stalePipeline(model.StatusSuccess) step := defaultStep(model.StatusSuccess) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("test")}, }) require.Error(t, err) assert.ErrorIs(t, err, ErrAgentIllegalLogStreaming) }) t.Run("reject: step killed, pipeline killed stale", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := defaultAgent() pipeline := stalePipeline(model.StatusKilled) step := defaultStep(model.StatusKilled) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("test")}, }) require.Error(t, err) assert.ErrorIs(t, err, ErrAgentIllegalLogStreaming) }) t.Run("reject mismatched step UUID in log entry", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockLogStore := log_mocks.NewMockService(t) origLogStore := server.Config.Services.LogStore server.Config.Services.LogStore = mockLogStore t.Cleanup(func() { server.Config.Services.LogStore = origLogStore }) agent := defaultAgent() pipeline := defaultPipeline(model.StatusRunning) step := defaultStep(model.StatusRunning) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(1)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) // Second entry has a rogue UUID — agent trying to inject into another step. entries := []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Line: 0, Data: []byte("ok")}, {StepUUID: "DIFFERENT-UUID", Line: 1, Data: []byte("injected!")}, } err := rpcInst.Log(ctx, "step-uuid-123", entries) require.Error(t, err) assert.Contains(t, err.Error(), "expected step UUID") }) t.Run("reject agent wrong org", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := orgAgent999() pipeline := defaultPipeline(model.StatusRunning) step := defaultStep(model.StatusRunning) mockStore.On("StepByUUID", "step-uuid-123").Return(step, nil) mockStore.On("AgentFind", int64(2)).Return(agent, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(2)) err := rpcInst.Log(ctx, "step-uuid-123", []*rpc.LogEntry{ {StepUUID: "step-uuid-123", Data: []byte("test")}, }) require.Error(t, err) assert.Contains(t, err.Error(), "not allowed to interact") }) t.Run("reject nonexistent step UUID", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("StepByUUID", "nonexistent").Return(nil, errors.New("not found")) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(1)) err := rpcInst.Log(ctx, "nonexistent", []*rpc.LogEntry{ {StepUUID: "nonexistent", Data: []byte("test")}, }) assert.Error(t, err) assert.Contains(t, err.Error(), "could not find step") }) } func TestRPCExtend(t *testing.T) { t.Run("reject agent wrong org via permission check", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := orgAgent999() workflow := defaultWorkflow(model.StatusRunning) pipeline := defaultPipeline(model.StatusRunning) mockStore.On("AgentFind", int64(2)).Return(agent, nil) mockStore.On("AgentUpdate", mock.Anything).Return(nil) // checkAgentPermissionByWorkflow with nil pipeline/repo -> loads from store mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(2)) err := rpcInst.Extend(ctx, "30") require.Error(t, err) assert.Contains(t, err.Error(), "not allowed to interact") }) } func TestRPCWait(t *testing.T) { t.Run("reject agent wrong org", func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) agent := orgAgent999() workflow := defaultWorkflow(model.StatusRunning) pipeline := defaultPipeline(model.StatusRunning) mockStore.On("AgentFind", int64(2)).Return(agent, nil) // checkAgentPermissionByWorkflow loads from store mockStore.On("WorkflowLoad", int64(30)).Return(workflow, nil) mockStore.On("GetPipeline", int64(20)).Return(pipeline, nil) mockStore.On("GetRepo", int64(10)).Return(defaultRepo(), nil) rpcInst := newTestRPC(t, mockStore, nil) ctx := context.WithValue(t.Context(), agentIDKey, int64(2)) _, err := rpcInst.Wait(ctx, "30") require.Error(t, err) assert.Contains(t, err.Error(), "not allowed to interact") }) } ================================================ FILE: server/rpc/rpc_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestRegisterAgent(t *testing.T) { t.Run("When existing agent Name is empty it should update Name with hostname from metadata", func(t *testing.T) { store := store_mocks.NewMockStore(t) storeAgent := new(model.Agent) storeAgent.ID = 1337 updatedAgent := model.Agent{ ID: 1337, Created: 0, Updated: 0, Name: "hostname", OwnerID: 0, Token: "", LastContact: 0, Platform: "platform", Backend: "backend", Capacity: 2, Version: "version", NoSchedule: false, } store.On("AgentFind", int64(1337)).Once().Return(storeAgent, nil) store.On("AgentUpdate", &updatedAgent).Once().Return(nil) grpc := RPC{ store: store, } ctx := metadata.NewIncomingContext( t.Context(), metadata.Pairs("hostname", "hostname"), ) ctx = context.WithValue(ctx, agentIDKey, int64(1337)) agentID, err := grpc.RegisterAgent(ctx, rpc.AgentInfo{ Version: "version", Platform: "platform", Backend: "backend", Capacity: 2, }) require.NoError(t, err) assert.EqualValues(t, 1337, agentID) }) t.Run("When existing agent hostname is present it should not update the hostname", func(t *testing.T) { store := store_mocks.NewMockStore(t) storeAgent := new(model.Agent) storeAgent.ID = 1337 storeAgent.Name = "originalHostname" updatedAgent := model.Agent{ ID: 1337, Created: 0, Updated: 0, Name: "originalHostname", OwnerID: 0, Token: "", LastContact: 0, Platform: "platform", Backend: "backend", Capacity: 2, Version: "version", NoSchedule: false, } store.On("AgentFind", int64(1337)).Once().Return(storeAgent, nil) store.On("AgentUpdate", &updatedAgent).Once().Return(nil) grpc := RPC{ store: store, } ctx := metadata.NewIncomingContext( t.Context(), metadata.Pairs("hostname", "newHostname"), ) ctx = context.WithValue(ctx, agentIDKey, int64(1337)) agentID, err := grpc.RegisterAgent(ctx, rpc.AgentInfo{ Version: "version", Platform: "platform", Backend: "backend", Capacity: 2, }) require.NoError(t, err) assert.EqualValues(t, 1337, agentID) }) } func TestCompleteChildrenIfParentCompleted(t *testing.T) { t.Run("When a service step is still running it should update state so workflow finishes as success", func(t *testing.T) { successStep := &model.Step{ ID: 1, State: model.StatusSuccess, Started: 1234567800, } runningService := &model.Step{ ID: 2, State: model.StatusRunning, Started: 1234567800, } workflow := model.Workflow{ ID: 7, State: model.StatusRunning, Children: []*model.Step{successStep, runningService}, } mockStore := store_mocks.NewMockStore(t) mockStore.On("StepUpdate", mock.Anything).Return(nil) mockStore.On("WorkflowUpdate", mock.Anything).Return(nil) s := RPC{store: mockStore} s.completeChildrenIfParentCompleted(&workflow, 1234567900) assert.Equal(t, model.StatusSuccess, runningService.State) assert.Equal(t, int64(1234567900), runningService.Finished) result, err := pipeline.UpdateWorkflowStatusToDone(mockStore, workflow, rpc.WorkflowState{ Started: 1234567800, Finished: 1234567900, }) require.NoError(t, err) assert.Equal(t, model.StatusSuccess, result.State) }) } func TestUpdateAgentLastWork(t *testing.T) { t.Run("When last work was never updated it should update last work timestamp", func(t *testing.T) { agent := model.Agent{ LastWork: 0, } store := store_mocks.NewMockStore(t) rpc := RPC{ store: store, } store.On("AgentUpdate", mock.Anything).Once().Return(nil) err := rpc.updateAgentLastWork(&agent) assert.NoError(t, err) assert.NotZero(t, agent.LastWork) }) t.Run("When last work was updated over a minute ago it should update last work timestamp", func(t *testing.T) { lastWork := time.Now().Add(-time.Hour).Unix() agent := model.Agent{ LastWork: lastWork, } store := store_mocks.NewMockStore(t) rpc := RPC{ store: store, } store.On("AgentUpdate", mock.Anything).Once().Return(nil) err := rpc.updateAgentLastWork(&agent) assert.NoError(t, err) assert.NotEqual(t, lastWork, agent.LastWork) }) t.Run("When last work was updated in the last minute it should not update last work timestamp again", func(t *testing.T) { lastWork := time.Now().Add(-time.Second * 30).Unix() agent := model.Agent{ LastWork: lastWork, } rpc := RPC{} err := rpc.updateAgentLastWork(&agent) assert.NoError(t, err) assert.Equal(t, lastWork, agent.LastWork) }) } ================================================ FILE: server/rpc/sanitize.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "fmt" "strconv" "time" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pipeline" ) const logStreamDelayAllowed = 5 * time.Minute func (s *RPC) checkAgentPermissionByWorkflow(_ context.Context, agent *model.Agent, strWorkflowID string, p *model.Pipeline, repo *model.Repo) error { var err error if repo == nil && p == nil { workflowID, err := strconv.ParseInt(strWorkflowID, 10, 64) if err != nil { return err } workflow, err := s.store.WorkflowLoad(workflowID) if err != nil { log.Error().Err(err).Msgf("cannot find workflow with id %d", workflowID) return err } p, err = s.store.GetPipeline(workflow.PipelineID) if err != nil { log.Error().Err(err).Msgf("cannot find pipeline with id %d", workflow.PipelineID) return err } } if repo == nil { repo, err = s.store.GetRepo(p.RepoID) if err != nil { log.Error().Err(err).Msgf("cannot find repo with id %d", p.RepoID) return err } } if agent.CanAccessRepo(repo) { return nil } log.Error().Err(ErrAgentIllegalRepo).Int64("agentID", agent.ID).Int64("repoId", repo.ID).Send() return fmt.Errorf("%w: agentId=%d repoID=%d", ErrAgentIllegalRepo, agent.ID, repo.ID) } // isActiveState returns true for states where work is in progress or not yet started. func isActiveState(state model.StatusValue) bool { switch state { case model.StatusCreated, model.StatusPending, model.StatusRunning: return true default: return false } } // isDoneState returns true for terminal states where no further work will happen. func isDoneState(state model.StatusValue) bool { switch state { case model.StatusSuccess, model.StatusFailure, model.StatusKilled, model.StatusCanceled, model.StatusSkipped, model.StatusError, model.StatusDeclined: return true default: return false } } // checkWorkflowAllowsStepUpdate validates whether the workflow state permits // the given step state update. If the workflow is active (created/pending/running), // any step update is allowed. If the workflow is in a terminal state, only // updates that would move the step into a terminal state are permitted — this // lets the agent report final results for steps that completed after the // workflow was already marked done. func checkWorkflowAllowsStepUpdate(workflowState model.StatusValue, step *model.Step, state rpc.StepState) error { if isActiveState(workflowState) { return nil } newStep, _, err := pipeline.CalcStepStatus(*step, state) if err != nil { return err } if isDoneState(newStep.State) { return nil } retErr := ErrAgentIllegalWorkflowReRunStateChange log.Error().Err(retErr).Msg("caught agent performing illegal instruction") return retErr } // checkWorkflowState checks if a workflow's own state allows it to be // initialized or marked as done. A workflow that is already in a terminal // state (success, failure, killed, …) must not be re-run, and a blocked // workflow must not be started by an agent. func checkWorkflowState(state model.StatusValue) (err error) { switch state { case model.StatusCreated, model.StatusPending, model.StatusRunning: return nil case model.StatusBlocked: err = ErrAgentIllegalWorkflowRun default: err = ErrAgentIllegalWorkflowReRunStateChange } if err != nil { log.Error().Err(err).Msg("caught agent performing illegal instruction") } return err } func allowAppendingLogs(currPipeline *model.Pipeline, currStep *model.Step) error { // As long as pipeline is running just let the agent send logs if currStep.State == model.StatusRunning || currPipeline.Status == model.StatusRunning { return nil } // else give some delay where log caches can drain and be send ... because of network outage / server restart / ... if time.Unix(currPipeline.Finished, 0).Add(logStreamDelayAllowed).After(time.Now()) { return nil } err := ErrAgentIllegalLogStreaming log.Error().Err(err).Msg("caught agent performing illegal instruction") return err } ================================================ FILE: server/rpc/sanitize_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "fmt" "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestCheckWorkflowAllowsStepUpdate(t *testing.T) { t.Parallel() t.Run("workflow running allows any step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} // Non-terminal update (step stays running) assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusRunning, step, rpc.StepState{})) }) t.Run("workflow pending allows any step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusPending, step, rpc.StepState{})) }) t.Run("workflow created allows any step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusCreated, step, rpc.StepState{})) }) t.Run("workflow finished allows terminal step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} // Step exits with code 0 → CalcStepStatus produces StatusSuccess (terminal) state := rpc.StepState{Exited: true, ExitCode: 0} assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state)) }) t.Run("workflow finished allows failed step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} state := rpc.StepState{Exited: true, ExitCode: 1} assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusFailure, step, state)) }) t.Run("workflow finished allows canceled step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} state := rpc.StepState{Canceled: true} assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusKilled, step, state)) }) t.Run("workflow finished allows skipped step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} state := rpc.StepState{Skipped: true} assert.NoError(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state)) }) t.Run("workflow finished rejects non-terminal step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} // No exit, no cancel → step stays Running (non-terminal) state := rpc.StepState{} assert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state), ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("workflow killed rejects non-terminal step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} state := rpc.StepState{} assert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusKilled, step, state), ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("workflow blocked rejects non-terminal step update", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusRunning} state := rpc.StepState{} assert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusBlocked, step, state), ErrAgentIllegalWorkflowReRunStateChange) }) t.Run("workflow finished rejects pending-to-running transition", func(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusPending} // No skip, no exit → CalcStepStatus produces Running (non-terminal) state := rpc.StepState{Started: 100} assert.ErrorIs(t, checkWorkflowAllowsStepUpdate(model.StatusSuccess, step, state), ErrAgentIllegalWorkflowReRunStateChange) }) } func TestCheckWorkflowState(t *testing.T) { t.Parallel() t.Run("allowed states", func(t *testing.T) { t.Parallel() for _, s := range []model.StatusValue{ model.StatusCreated, model.StatusPending, model.StatusRunning, } { t.Run(string(s), func(t *testing.T) { t.Parallel() assert.NoError(t, checkWorkflowState(s)) }) } }) t.Run("blocked rejects", func(t *testing.T) { t.Parallel() assert.ErrorIs(t, checkWorkflowState(model.StatusBlocked), ErrAgentIllegalWorkflowRun) }) t.Run("terminal states reject", func(t *testing.T) { t.Parallel() for _, s := range []model.StatusValue{ model.StatusSuccess, model.StatusFailure, model.StatusKilled, model.StatusError, model.StatusSkipped, model.StatusCanceled, model.StatusDeclined, } { t.Run(string(s), func(t *testing.T) { t.Parallel() assert.ErrorIs(t, checkWorkflowState(s), ErrAgentIllegalWorkflowReRunStateChange) }) } }) } func TestIsActiveState(t *testing.T) { t.Parallel() active := []model.StatusValue{model.StatusCreated, model.StatusPending, model.StatusRunning} inactive := []model.StatusValue{ model.StatusSuccess, model.StatusFailure, model.StatusKilled, model.StatusBlocked, model.StatusCanceled, model.StatusSkipped, model.StatusError, model.StatusDeclined, } for _, s := range active { t.Run(fmt.Sprintf("%s is active", s), func(t *testing.T) { t.Parallel() assert.True(t, isActiveState(s)) }) } for _, s := range inactive { t.Run(fmt.Sprintf("%s is not active", s), func(t *testing.T) { t.Parallel() assert.False(t, isActiveState(s)) }) } } func TestIsDoneState(t *testing.T) { t.Parallel() done := []model.StatusValue{ model.StatusSuccess, model.StatusFailure, model.StatusKilled, model.StatusCanceled, model.StatusSkipped, model.StatusError, model.StatusDeclined, } notDone := []model.StatusValue{ model.StatusCreated, model.StatusPending, model.StatusRunning, model.StatusBlocked, } for _, s := range done { t.Run(fmt.Sprintf("%s is done", s), func(t *testing.T) { t.Parallel() assert.True(t, isDoneState(s)) }) } for _, s := range notDone { t.Run(fmt.Sprintf("%s is not done", s), func(t *testing.T) { t.Parallel() assert.False(t, isDoneState(s)) }) } } func TestAllowAppendingLogs(t *testing.T) { t.Parallel() recentFinish := time.Now().Add(-30 * time.Second).Unix() staleFinish := time.Now().Add(-10 * time.Minute).Unix() // Step running always allows logs, regardless of pipeline state or age. t.Run("step running always allowed", func(t *testing.T) { t.Parallel() for _, tc := range []struct { name string status model.StatusValue finish int64 }{ {"pipeline running", model.StatusRunning, 0}, {"pipeline success stale", model.StatusSuccess, staleFinish}, {"pipeline failure stale", model.StatusFailure, staleFinish}, {"pipeline killed stale", model.StatusKilled, staleFinish}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() p := &model.Pipeline{Status: tc.status, Finished: tc.finish} assert.NoError(t, allowAppendingLogs(p, &model.Step{State: model.StatusRunning})) }) } }) // Pipeline running allows logs for any step state. t.Run("pipeline running any step allowed", func(t *testing.T) { t.Parallel() for _, ss := range []model.StatusValue{ model.StatusSuccess, model.StatusFailure, model.StatusPending, model.StatusKilled, } { t.Run(string(ss), func(t *testing.T) { t.Parallel() p := &model.Pipeline{Status: model.StatusRunning} assert.NoError(t, allowAppendingLogs(p, &model.Step{State: ss})) }) } }) // Recent finish → drain window allows logs. t.Run("recent finish drain allowed", func(t *testing.T) { t.Parallel() for _, tc := range []struct { pStatus model.StatusValue sState model.StatusValue }{ {model.StatusSuccess, model.StatusSuccess}, {model.StatusFailure, model.StatusFailure}, {model.StatusKilled, model.StatusPending}, } { t.Run(fmt.Sprintf("%s/%s", tc.pStatus, tc.sState), func(t *testing.T) { t.Parallel() p := &model.Pipeline{Status: tc.pStatus, Finished: recentFinish} assert.NoError(t, allowAppendingLogs(p, &model.Step{State: tc.sState})) }) } }) // Stale finish → drain window expired → reject. t.Run("stale finish drain rejected", func(t *testing.T) { t.Parallel() for _, tc := range []struct { pStatus model.StatusValue sState model.StatusValue finish int64 }{ {model.StatusSuccess, model.StatusSuccess, staleFinish}, {model.StatusFailure, model.StatusFailure, staleFinish}, {model.StatusKilled, model.StatusPending, staleFinish}, {model.StatusError, model.StatusCreated, staleFinish}, {model.StatusSuccess, model.StatusSuccess, 0}, // zero = never recorded } { t.Run(fmt.Sprintf("%s/%s/fin=%d", tc.pStatus, tc.sState, tc.finish), func(t *testing.T) { t.Parallel() p := &model.Pipeline{Status: tc.pStatus, Finished: tc.finish} assert.ErrorIs(t, allowAppendingLogs(p, &model.Step{State: tc.sState}), ErrAgentIllegalLogStreaming) }) } }) } // TestAllowAppendingLogsDrainBoundary guards the exact edge of the 5-minute // drain window against off-by-one errors. func TestAllowAppendingLogsDrainBoundary(t *testing.T) { t.Parallel() step := &model.Step{State: model.StatusSuccess} t.Run("just inside drain window allowed", func(t *testing.T) { t.Parallel() p := &model.Pipeline{ Status: model.StatusSuccess, Finished: time.Now().Add(-(logStreamDelayAllowed - time.Second)).Unix(), } assert.NoError(t, allowAppendingLogs(p, step)) }) t.Run("just outside drain window rejected", func(t *testing.T) { t.Parallel() p := &model.Pipeline{ Status: model.StatusSuccess, Finished: time.Now().Add(-(logStreamDelayAllowed + time.Second)).Unix(), } assert.ErrorIs(t, allowAppendingLogs(p, step), ErrAgentIllegalLogStreaming) }) } ================================================ FILE: server/rpc/server.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rpc import ( "context" "encoding/json" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/rpc" "go.woodpecker-ci.org/woodpecker/v3/rpc/proto" "go.woodpecker-ci.org/woodpecker/v3/server/logging" "go.woodpecker-ci.org/woodpecker/v3/server/scheduler" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/version" ) // WoodpeckerServer is a grpc server implementation. type WoodpeckerServer struct { proto.UnimplementedWoodpeckerServer peer RPC } func NewWoodpeckerServer(scheduler scheduler.Scheduler, logger logging.Log, store store.Store) proto.WoodpeckerServer { pipelineTime := promauto.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "pipeline_time", Help: "Pipeline time.", }, []string{"repo", "branch", "status", "pipeline"}) pipelineCount := promauto.NewCounterVec(prometheus.CounterOpts{ Namespace: "woodpecker", Name: "pipeline_count", Help: "Pipeline count.", }, []string{"repo", "branch", "status", "pipeline"}) peer := RPC{ store: store, scheduler: scheduler, logger: logger, pipelineTime: pipelineTime, pipelineCount: pipelineCount, } return &WoodpeckerServer{peer: peer} } // NewTestWoodpeckerServer creates a WoodpeckerServer for e2e tests. // It is using a caller-supplied prometheus registry. // Use this in tests to avoid "duplicate metrics collector registration" panics when the server is created multiple times. // (promauto in NewWoodpeckerServer registers into the global default registry, which panics on duplicate names). func NewTestWoodpeckerServer(scheduler scheduler.Scheduler, logger logging.Log, store store.Store, registry *prometheus.Registry) proto.WoodpeckerServer { factory := promauto.With(registry) pipelineTime := factory.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "woodpecker", Name: "pipeline_time", Help: "Pipeline time.", }, []string{"repo", "branch", "status", "pipeline"}) pipelineCount := factory.NewCounterVec(prometheus.CounterOpts{ Namespace: "woodpecker", Name: "pipeline_count", Help: "Pipeline count.", }, []string{"repo", "branch", "status", "pipeline"}) peer := RPC{ store: store, scheduler: scheduler, logger: logger, pipelineTime: pipelineTime, pipelineCount: pipelineCount, } return &WoodpeckerServer{peer: peer} } // Version returns the server- & grpc-version. func (s *WoodpeckerServer) Version(_ context.Context, _ *proto.Empty) (*proto.VersionResponse, error) { return &proto.VersionResponse{ GrpcVersion: proto.Version, ServerVersion: version.String(), }, nil } // Next blocks until it provides the next workflow to execute from the queue. func (s *WoodpeckerServer) Next(c context.Context, req *proto.NextRequest) (*proto.NextResponse, error) { filter := rpc.Filter{ Labels: req.GetFilter().GetLabels(), } res := new(proto.NextResponse) pipeline, err := s.peer.Next(c, filter) if err != nil || pipeline == nil { return res, err } res.Workflow = new(proto.Workflow) res.Workflow.Id = pipeline.ID res.Workflow.Timeout = pipeline.Timeout res.Workflow.Payload, err = json.Marshal(pipeline.Config) return res, err } // Init let agent signals to server the workflow is initialized. func (s *WoodpeckerServer) Init(c context.Context, req *proto.InitRequest) (*proto.Empty, error) { state := rpc.WorkflowState{ Started: req.GetState().GetStarted(), Finished: req.GetState().GetFinished(), Error: req.GetState().GetError(), } res := new(proto.Empty) err := s.peer.Init(c, req.GetId(), state) return res, err } // Update let agent updates the step state at the server. func (s *WoodpeckerServer) Update(c context.Context, req *proto.UpdateRequest) (*proto.Empty, error) { state := rpc.StepState{ StepUUID: req.GetState().GetStepUuid(), Started: req.GetState().GetStarted(), Finished: req.GetState().GetFinished(), Exited: req.GetState().GetExited(), Error: req.GetState().GetError(), ExitCode: int(req.GetState().GetExitCode()), Canceled: req.GetState().GetCanceled(), Skipped: req.GetState().GetSkipped(), } res := new(proto.Empty) err := s.peer.Update(c, req.GetId(), state) return res, err } // Done let agent signal to server the workflow has stopped. func (s *WoodpeckerServer) Done(c context.Context, req *proto.DoneRequest) (*proto.Empty, error) { state := rpc.WorkflowState{ Started: req.GetState().GetStarted(), Finished: req.GetState().GetFinished(), Error: req.GetState().GetError(), Canceled: req.GetState().GetCanceled(), } res := new(proto.Empty) err := s.peer.Done(c, req.GetId(), state) return res, err } // Wait blocks until the workflow is complete. // Also signals via err if workflow got canceled. func (s *WoodpeckerServer) Wait(c context.Context, req *proto.WaitRequest) (*proto.WaitResponse, error) { res := new(proto.WaitResponse) canceled, err := s.peer.Wait(c, req.GetId()) res.Canceled = canceled return res, err } // Extend extends the workflow deadline. func (s *WoodpeckerServer) Extend(c context.Context, req *proto.ExtendRequest) (*proto.Empty, error) { res := new(proto.Empty) err := s.peer.Extend(c, req.GetId()) return res, err } func (s *WoodpeckerServer) Log(c context.Context, req *proto.LogRequest) (*proto.Empty, error) { var ( entries []*rpc.LogEntry stepUUID string ) write := func() error { if len(entries) > 0 { if err := s.peer.Log(c, stepUUID, entries); err != nil { log.Error().Err(err).Msg("could not write log entries") return err } } return nil } for _, reqEntry := range req.GetLogEntries() { entry := &rpc.LogEntry{ Data: reqEntry.GetData(), Line: int(reqEntry.GetLine()), Time: reqEntry.GetTime(), StepUUID: reqEntry.GetStepUuid(), Type: int(reqEntry.GetType()), } if entry.StepUUID != stepUUID { _ = write() stepUUID = entry.StepUUID entries = entries[:0] } entries = append(entries, entry) } res := new(proto.Empty) err := write() return res, err } // RegisterAgent register our agent to the server. func (s *WoodpeckerServer) RegisterAgent(c context.Context, req *proto.RegisterAgentRequest) (*proto.RegisterAgentResponse, error) { res := new(proto.RegisterAgentResponse) agentInfo := req.GetInfo() agentID, err := s.peer.RegisterAgent(c, rpc.AgentInfo{ Version: agentInfo.GetVersion(), Platform: agentInfo.GetPlatform(), Backend: agentInfo.GetBackend(), Capacity: int(agentInfo.GetCapacity()), CustomLabels: agentInfo.GetCustomLabels(), }) res.AgentId = agentID return res, err } // UnregisterAgent unregister our agent from the server. func (s *WoodpeckerServer) UnregisterAgent(ctx context.Context, _ *proto.Empty) (*proto.Empty, error) { err := s.peer.UnregisterAgent(ctx) return new(proto.Empty), err } // ReportHealth reports health status of the agent to the server. func (s *WoodpeckerServer) ReportHealth(c context.Context, req *proto.ReportHealthRequest) (*proto.Empty, error) { res := new(proto.Empty) err := s.peer.ReportHealth(c, req.GetStatus()) return res, err } ================================================ FILE: server/scheduler/proxy.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package scheduler import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" "go.woodpecker-ci.org/woodpecker/v3/server/queue" ) type proxy struct { q queue.Queue ps pubsub.PubSub } // // Queue. // func (p *proxy) Done(c context.Context, id string, exitStatus model.StatusValue) error { return p.q.Done(c, id, exitStatus) } func (p *proxy) Error(c context.Context, id string, err error) error { return p.q.Error(c, id, err) } func (p *proxy) ErrorAtOnce(c context.Context, ids []string, err error) error { return p.q.ErrorAtOnce(c, ids, err) } func (p *proxy) Extend(c context.Context, agentID int64, workflowID string) error { return p.q.Extend(c, agentID, workflowID) } func (p *proxy) Info(c context.Context) queue.InfoT { return p.q.Info(c) } func (p *proxy) KickAgentWorkers(agentID int64) { p.q.KickAgentWorkers(agentID) } func (p *proxy) Pause() { p.q.Pause() } func (p *proxy) Poll(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error) { return p.q.Poll(c, agentID, f) } func (p *proxy) PushAtOnce(c context.Context, tasks []*model.Task) error { return p.q.PushAtOnce(c, tasks) } func (p *proxy) Resume() { p.q.Resume() } func (p *proxy) Wait(c context.Context, id string) error { return p.q.Wait(c, id) } // // PubSub. // func (p *proxy) Subscribe(c context.Context, t pubsub.Topics, r pubsub.Receiver) error { return p.ps.Subscribe(c, t, r) } func (p *proxy) Publish(c context.Context, t pubsub.Topics, m pubsub.Message) error { return p.ps.Publish(c, t, m) } ================================================ FILE: server/scheduler/scheduler.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package scheduler import ( "go.woodpecker-ci.org/woodpecker/v3/server/pubsub" "go.woodpecker-ci.org/woodpecker/v3/server/queue" ) // Scheduler is currently just the combined interface of Queue & PubSub. type Scheduler interface { queue.Queue pubsub.PubSub } func NewScheduler(q queue.Queue, ps pubsub.PubSub) Scheduler { return &proxy{ q: q, ps: ps, } } ================================================ FILE: server/services/config/combined.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type combined struct { services []Service } func NewCombined(services ...Service) Service { return &combined{services: services} } func (c *combined) Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) (files []*types.FileMeta, err error) { files = oldConfigData for _, s := range c.services { files, err = s.Fetch(ctx, forge, user, repo, pipeline, files, restart) } return files, err } ================================================ FILE: server/services/config/combined_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config_test import ( "crypto/ed25519" "crypto/rand" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/yaronf/httpsign" "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" ) func TestFetchFromConfigService(t *testing.T) { t.Parallel() type file struct { name string data []byte } dummyData := []byte("TEST") testTable := []struct { name string repoConfig string files []file expectedFileNames []string expectedError bool }{ { name: "External Fetch empty repo", repoConfig: "", files: []file{}, expectedFileNames: []string{"override1", "override2", "override3"}, expectedError: false, }, { name: "Default config - Additional sub-folders", repoConfig: "", files: []file{{ name: ".woodpecker/test.yml", data: dummyData, }, { name: ".woodpecker/sub-folder/config.yml", data: dummyData, }}, expectedFileNames: []string{"override1", "override2", "override3"}, expectedError: false, }, { name: "Fetch empty", repoConfig: " ", files: []file{{ name: ".woodpecker/.keep", data: dummyData, }, { name: ".woodpecker.yml", data: nil, }, { name: ".woodpecker.yaml", data: dummyData, }}, expectedFileNames: []string{}, expectedError: true, }, { name: "Use old config", repoConfig: ".my-ci-folder/", files: []file{{ name: ".woodpecker/test.yml", data: dummyData, }, { name: ".woodpecker.yml", data: dummyData, }, { name: ".woodpecker.yaml", data: dummyData, }, { name: ".my-ci-folder/test.yml", data: dummyData, }}, expectedFileNames: []string{ ".my-ci-folder/test.yml", }, expectedError: false, }, } pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal("can't generate ed25519 key pair") } fixtureHandler := func(w http.ResponseWriter, r *http.Request) { // check signature pubKeyID := "woodpecker-ci-extensions" verifier, err := httpsign.NewEd25519Verifier(pubEd25519Key, httpsign.NewVerifyConfig(), httpsign.Headers("@request-target", "content-digest")) // The Content-Digest header will be auto-generated assert.NoError(t, err) err = httpsign.VerifyRequest(pubKeyID, *verifier, r) if err != nil { http.Error(w, "Invalid signature", http.StatusBadRequest) return } type config struct { Name string `json:"name"` Data string `json:"data"` } type incoming struct { Repo *model.Repo `json:"repo"` Build *model.Pipeline `json:"pipeline"` Configuration []*config `json:"config"` } var req incoming body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "can't read body", http.StatusBadRequest) return } err = json.Unmarshal(body, &req) if err != nil { http.Error(w, "Failed to parse JSON"+err.Error(), http.StatusBadRequest) return } if req.Repo.Name == "Fetch empty" { w.WriteHeader(http.StatusNotFound) return } if req.Repo.Name == "Use old config" { w.WriteHeader(http.StatusNoContent) return } fmt.Fprint(w, `{ "configs": [ { "name": "override1", "data": "some new pipelineconfig \n pipe, pipe, pipe" }, { "name": "override2", "data": "some new pipelineconfig \n pipe, pipe, pipe" }, { "name": "override3", "data": "some new pipelineconfig \n pipe, pipe, pipe" } ] }`) } ts := httptest.NewServer(http.HandlerFunc(fixtureHandler)) defer ts.Close() client, err := utils.NewHTTPClient(privEd25519Key, "loopback") require.NoError(t, err) httpFetcher := config.NewHTTP(ts.URL+"/", client, true) for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { repo := &model.Repo{Owner: "laszlocph", Name: tt.name, Config: tt.repoConfig} // Using test name as repo name to provide different responses in mock server f := new(mocks.MockForge) dirs := map[string][]*forge_types.FileMeta{} for _, file := range tt.files { f.On("File", mock.Anything, mock.Anything, mock.Anything, mock.Anything, file.name).Return(file.data, nil) path := filepath.Dir(file.name) if path != "." { dirs[path] = append(dirs[path], &forge_types.FileMeta{ Name: file.name, Data: file.data, }) } } for path, files := range dirs { f.On("Dir", mock.Anything, mock.Anything, mock.Anything, mock.Anything, path).Return(files, nil) } // if the previous mocks do not match return not found errors f.On("File", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("file not found")) f.On("Dir", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("directory not found")) f.On("Netrc", mock.Anything, mock.Anything).Return(&model.Netrc{Machine: "mock", Login: "mock", Password: "mock"}, nil) forgeFetcher := config.NewForge(time.Second*3, 3) configFetcher := config.NewCombined(forgeFetcher, httpFetcher) files, err := configFetcher.Fetch( t.Context(), f, &model.User{AccessToken: "xxx"}, repo, &model.Pipeline{Commit: "89ab7b2d6bfb347144ac7c557e638ab402848fee"}, []*forge_types.FileMeta{}, false, ) if tt.expectedError && err == nil { t.Fatal("expected an error") } else if !tt.expectedError && err != nil { t.Fatal("error fetching config:", err) } matchingFiles := make([]string, len(files)) for i := range files { matchingFiles[i] = files[i].Name } assert.ElementsMatch(t, tt.expectedFileNames, matchingFiles, "expected some other pipeline files") }) } } ================================================ FILE: server/services/config/forge.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "context" "errors" "fmt" "strings" "time" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/constant" ) type forgeFetcher struct { timeout time.Duration retryCount uint } func NewForge(timeout time.Duration, retries uint) Service { return &forgeFetcher{ timeout: timeout, retryCount: retries, } } func (f *forgeFetcher) Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) (files []*types.FileMeta, err error) { // skip fetching if we are restarting and have the old config if restart && len(oldConfigData) > 0 { return oldConfigData, nil } ffc := &forgeFetcherContext{ forge: forge, user: user, repo: repo, pipeline: pipeline, timeout: f.timeout, } // try to fetch multiple times for i := 0; i < int(f.retryCount); i++ { files, err = ffc.fetch(ctx, strings.TrimSpace(repo.Config)) if err != nil { log.Trace().Err(err).Msgf("Fetching config files: Attempt #%d failed", i+1) } else { break } } return files, err } type forgeFetcherContext struct { forge forge.Forge user *model.User repo *model.Repo pipeline *model.Pipeline timeout time.Duration } // fetch attempts to fetch the configuration file(s) for the given config string. func (f *forgeFetcherContext) fetch(c context.Context, config string) ([]*types.FileMeta, error) { ctx, cancel := context.WithTimeout(c, f.timeout) defer cancel() if len(config) > 0 { log.Trace().Msgf("configFetcher[%s]: use user config '%s'", f.repo.FullName, config) // could be adapted to allow the user to supply a list like we do in the defaults configs := []string{config} fileMetas, err := f.getFirstAvailableConfig(ctx, configs) if err == nil { return fileMetas, nil } return nil, fmt.Errorf("user defined config '%s' not found: %w", config, err) } log.Trace().Msgf("configFetcher[%s]: user did not define own config, following default procedure", f.repo.FullName) // for the order see shared/constants/constants.go fileMetas, err := f.getFirstAvailableConfig(ctx, constant.DefaultConfigOrder[:]) if err == nil { return fileMetas, nil } select { case <-ctx.Done(): return nil, ctx.Err() default: return nil, fmt.Errorf("configFetcher: fallback did not find config: %w", err) } } func filterPipelineFiles(files []*types.FileMeta) []*types.FileMeta { var res []*types.FileMeta for _, file := range files { if strings.HasSuffix(file.Name, ".yml") || strings.HasSuffix(file.Name, ".yaml") { res = append(res, file) } } return res } func (f *forgeFetcherContext) checkPipelineFile(c context.Context, config string) ([]*types.FileMeta, error) { file, err := f.forge.File(c, f.user, f.repo, f.pipeline, config) if err == nil && len(file) != 0 { log.Trace().Msgf("configFetcher[%s]: found file '%s'", f.repo.FullName, config) return []*types.FileMeta{{ Name: config, Data: file, }}, nil } return nil, err } func (f *forgeFetcherContext) getFirstAvailableConfig(c context.Context, configs []string) ([]*types.FileMeta, error) { var forgeErr []error for _, fileOrFolder := range configs { log.Trace().Msgf("fetching %s from forge", fileOrFolder) if strings.HasSuffix(fileOrFolder, "/") { // config is a folder files, err := f.forge.Dir(c, f.user, f.repo, f.pipeline, strings.TrimSuffix(fileOrFolder, "/")) // if folder is not supported we will get a "Not implemented" error and continue if err != nil { if errors.Is(err, types.ErrNotImplemented) { log.Debug().Msg("Could not fetch config folder as forge adapter did not implement it") } else if !errors.Is(err, &types.ErrConfigNotFound{}) { log.Error().Err(err).Str("repo", f.repo.FullName).Str("user", f.user.Login).Msgf("could not get folder from forge: %s", err) forgeErr = append(forgeErr, err) } continue } files = filterPipelineFiles(files) if len(files) != 0 { log.Trace().Msgf("configFetcher[%s]: found %d files in '%s'", f.repo.FullName, len(files), fileOrFolder) return files, nil } } // config is a file if fileMeta, err := f.checkPipelineFile(c, fileOrFolder); err == nil { log.Trace().Msgf("configFetcher[%s]: found file: '%s'", f.repo.FullName, fileOrFolder) return fileMeta, nil } else if !errors.Is(err, &types.ErrConfigNotFound{}) { forgeErr = append(forgeErr, err) } } // got unexpected errors if len(forgeErr) != 0 { return nil, errors.Join(forgeErr...) } // nothing found return nil, &types.ErrConfigNotFound{Configs: configs} } ================================================ FILE: server/services/config/forge_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config_test import ( "fmt" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge/mocks" forge_types "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" ) func TestFetch(t *testing.T) { t.Parallel() type file struct { name string data []byte } dummyData := []byte("TEST") testTable := []struct { name string repoConfig string files []file expectedFileNames []string expectedError bool }{ { name: "Default config - .woodpecker/", repoConfig: "", files: []file{{ name: ".woodpecker/text.txt", data: dummyData, }, { name: ".woodpecker/release.yml", data: dummyData, }, { name: ".woodpecker/image.png", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker/release.yml", }, expectedError: false, }, { name: "Default config with .yaml - .woodpecker/", repoConfig: "", files: []file{{ name: ".woodpecker/text.txt", data: dummyData, }, { name: ".woodpecker/release.yaml", data: dummyData, }, { name: ".woodpecker/image.png", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker/release.yaml", }, expectedError: false, }, { name: "Default config with .yaml, .yml mix - .woodpecker/", repoConfig: "", files: []file{{ name: ".woodpecker/text.txt", data: dummyData, }, { name: ".woodpecker/release.yaml", data: dummyData, }, { name: ".woodpecker/other.yml", data: dummyData, }, { name: ".woodpecker/image.png", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker/release.yaml", ".woodpecker/other.yml", }, expectedError: false, }, { name: "Default config check .woodpecker.yaml before .woodpecker.yml", repoConfig: "", files: []file{{ name: ".woodpecker.yaml", data: dummyData, }, { name: ".woodpecker.yml", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker.yaml", }, expectedError: false, }, { name: "Override via API with custom config", repoConfig: "", files: []file{{ name: ".woodpecker.yml", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker.yml", }, expectedError: false, }, { name: "Use old config on 204 response", repoConfig: "", files: []file{{ name: ".woodpecker.yaml", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker.yaml", }, expectedError: false, }, { name: "Default config - Empty repo", repoConfig: "", files: []file{}, expectedFileNames: []string{}, expectedError: true, }, { name: "Default config - Additional sub-folders", repoConfig: "", files: []file{{ name: ".woodpecker/test.yml", data: dummyData, }, { name: ".woodpecker/sub-folder/config.yml", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker/test.yml", }, expectedError: false, }, { name: "Default config - Additional none .yml files", repoConfig: "", files: []file{{ name: ".woodpecker/notes.txt", data: dummyData, }, { name: ".woodpecker/image.png", data: dummyData, }, { name: ".woodpecker/test.yml", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker/test.yml", }, expectedError: false, }, { name: "Default config - Empty Folder", repoConfig: " ", files: []file{{ name: ".woodpecker/.keep", data: dummyData, }, { name: ".woodpecker.yml", data: nil, }, { name: ".woodpecker.yaml", data: dummyData, }}, expectedFileNames: []string{ ".woodpecker.yaml", }, expectedError: false, }, { name: "Special config - folder (ignoring default files)", repoConfig: ".my-ci-folder/", files: []file{{ name: ".woodpecker/test.yml", data: dummyData, }, { name: ".woodpecker.yml", data: dummyData, }, { name: ".woodpecker.yaml", data: dummyData, }, { name: ".my-ci-folder/test.yml", data: dummyData, }}, expectedFileNames: []string{ ".my-ci-folder/test.yml", }, expectedError: false, }, { name: "Special config - folder", repoConfig: ".my-ci-folder/", files: []file{{ name: ".my-ci-folder/test.yml", data: dummyData, }}, expectedFileNames: []string{ ".my-ci-folder/test.yml", }, expectedError: false, }, { name: "Special config - subfolder", repoConfig: ".my-ci-folder/my-config/", files: []file{{ name: ".my-ci-folder/my-config/test.yml", data: dummyData, }}, expectedFileNames: []string{ ".my-ci-folder/my-config/test.yml", }, expectedError: false, }, { name: "Special config - file", repoConfig: ".config.yml", files: []file{{ name: ".config.yml", data: dummyData, }}, expectedFileNames: []string{ ".config.yml", }, expectedError: false, }, { name: "Special config - file inside subfolder", repoConfig: ".my-ci-folder/sub-folder/config.yml", files: []file{{ name: ".my-ci-folder/sub-folder/config.yml", data: dummyData, }}, expectedFileNames: []string{ ".my-ci-folder/sub-folder/config.yml", }, expectedError: false, }, { name: "Special config - empty repo", repoConfig: ".config.yml", files: []file{}, expectedFileNames: []string{}, expectedError: true, }, } for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { repo := &model.Repo{Owner: "laszlocph", Name: "multipipeline", Config: tt.repoConfig} f := new(mocks.MockForge) dirs := map[string][]*forge_types.FileMeta{} for _, file := range tt.files { f.On("File", mock.Anything, mock.Anything, mock.Anything, mock.Anything, file.name).Once().Return(file.data, nil) path := filepath.Dir(file.name) if path != "." { dirs[path] = append(dirs[path], &forge_types.FileMeta{ Name: file.name, Data: file.data, }) } } for path, files := range dirs { f.On("Dir", mock.Anything, mock.Anything, mock.Anything, mock.Anything, path).Once().Return(files, nil) } // if the previous mocks do not match return not found errors f.On("File", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("file not found")) f.On("Dir", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, fmt.Errorf("directory not found")) configFetcher := config.NewForge( time.Second*3, 3, ) files, err := configFetcher.Fetch( t.Context(), f, &model.User{AccessToken: "xxx"}, repo, &model.Pipeline{Commit: "89ab7b2d6bfb347144ac7c557e638ab402848fee"}, nil, false, ) if tt.expectedError && err == nil { t.Fatal("expected an error") } else if !tt.expectedError && err != nil { t.Fatal("error fetching config:", err) } matchingFiles := make([]string, len(files)) for i := range files { matchingFiles[i] = files[i].Name } assert.ElementsMatch(t, tt.expectedFileNames, matchingFiles, "expected some other pipeline files") }) } } ================================================ FILE: server/services/config/http.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "context" "fmt" "net/http" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" ) type httpService struct { endpoint string client *utils.Client includeNetrc bool } // configData same as forge.FileMeta but with json tags and string data. type configData struct { Name string `json:"name"` Data string `json:"data"` } type requestStructure struct { Repo *model.Repo `json:"repo"` Pipeline *model.Pipeline `json:"pipeline"` Netrc *model.Netrc `json:"netrc"` Configuration []*configData `json:"configuration,omitempty"` } type responseStructure struct { Configs []*configData `json:"configs"` } func NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) Service { return &httpService{endpoint, client, includeNetrc} } func (h *httpService) Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, _ bool) ([]*types.FileMeta, error) { configuration := make([]*configData, len(oldConfigData)) for i, oldConfig := range oldConfigData { configuration[i] = &configData{Name: oldConfig.Name, Data: string(oldConfig.Data)} } response := new(responseStructure) body := requestStructure{ Repo: repo, Pipeline: pipeline, Configuration: configuration, } if h.includeNetrc { netrc, err := forge.Netrc(user, repo) if err != nil { return nil, fmt.Errorf("could not get Netrc data from forge: %w", err) } body.Netrc = netrc } status, err := h.client.Send(ctx, http.MethodPost, h.endpoint, body, response) if err != nil && status != http.StatusNoContent { return nil, fmt.Errorf("failed to fetch config via http (status: %d): %w", status, err) } // handle 204 - no new config available, return old config without error if status == http.StatusNoContent { log.Debug(). Str("endpoint", h.endpoint). Str("repo", repo.FullName). Msg("config endpoint returned 204 No Content, using fallback config") return oldConfigData, nil } // unexpected non-success status code if status != http.StatusOK { return oldConfigData, fmt.Errorf("unexpected status code %d from config endpoint (expected 200 or 204)", status) } fileMetaList := make([]*types.FileMeta, len(response.Configs)) for i, config := range response.Configs { fileMetaList[i] = &types.FileMeta{Name: config.Name, Data: []byte(config.Data)} } return fileMetaList, nil } ================================================ FILE: server/services/config/mocks/mock_Service.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockService(t interface { mock.TestingT Cleanup(func()) }) *MockService { mock := &MockService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockService is an autogenerated mock type for the Service type type MockService struct { mock.Mock } type MockService_Expecter struct { mock *mock.Mock } func (_m *MockService) EXPECT() *MockService_Expecter { return &MockService_Expecter{mock: &_m.Mock} } // Fetch provides a mock function for the type MockService func (_mock *MockService) Fetch(ctx context.Context, forge1 forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) ([]*types.FileMeta, error) { ret := _mock.Called(ctx, forge1, user, repo, pipeline, oldConfigData, restart) if len(ret) == 0 { panic("no return value specified for Fetch") } var r0 []*types.FileMeta var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, forge.Forge, *model.User, *model.Repo, *model.Pipeline, []*types.FileMeta, bool) ([]*types.FileMeta, error)); ok { return returnFunc(ctx, forge1, user, repo, pipeline, oldConfigData, restart) } if returnFunc, ok := ret.Get(0).(func(context.Context, forge.Forge, *model.User, *model.Repo, *model.Pipeline, []*types.FileMeta, bool) []*types.FileMeta); ok { r0 = returnFunc(ctx, forge1, user, repo, pipeline, oldConfigData, restart) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*types.FileMeta) } } if returnFunc, ok := ret.Get(1).(func(context.Context, forge.Forge, *model.User, *model.Repo, *model.Pipeline, []*types.FileMeta, bool) error); ok { r1 = returnFunc(ctx, forge1, user, repo, pipeline, oldConfigData, restart) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_Fetch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fetch' type MockService_Fetch_Call struct { *mock.Call } // Fetch is a helper method to define mock.On call // - ctx context.Context // - forge1 forge.Forge // - user *model.User // - repo *model.Repo // - pipeline *model.Pipeline // - oldConfigData []*types.FileMeta // - restart bool func (_e *MockService_Expecter) Fetch(ctx interface{}, forge1 interface{}, user interface{}, repo interface{}, pipeline interface{}, oldConfigData interface{}, restart interface{}) *MockService_Fetch_Call { return &MockService_Fetch_Call{Call: _e.mock.On("Fetch", ctx, forge1, user, repo, pipeline, oldConfigData, restart)} } func (_c *MockService_Fetch_Call) Run(run func(ctx context.Context, forge1 forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool)) *MockService_Fetch_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 forge.Forge if args[1] != nil { arg1 = args[1].(forge.Forge) } var arg2 *model.User if args[2] != nil { arg2 = args[2].(*model.User) } var arg3 *model.Repo if args[3] != nil { arg3 = args[3].(*model.Repo) } var arg4 *model.Pipeline if args[4] != nil { arg4 = args[4].(*model.Pipeline) } var arg5 []*types.FileMeta if args[5] != nil { arg5 = args[5].([]*types.FileMeta) } var arg6 bool if args[6] != nil { arg6 = args[6].(bool) } run( arg0, arg1, arg2, arg3, arg4, arg5, arg6, ) }) return _c } func (_c *MockService_Fetch_Call) Return(configData []*types.FileMeta, err error) *MockService_Fetch_Call { _c.Call.Return(configData, err) return _c } func (_c *MockService_Fetch_Call) RunAndReturn(run func(ctx context.Context, forge1 forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) ([]*types.FileMeta, error)) *MockService_Fetch_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/services/config/service.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/forge/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type Service interface { Fetch(ctx context.Context, forge forge.Forge, user *model.User, repo *model.Repo, pipeline *model.Pipeline, oldConfigData []*types.FileMeta, restart bool) (configData []*types.FileMeta, err error) } ================================================ FILE: server/services/encryption/aes.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "crypto/cipher" "encoding/base64" "fmt" "github.com/tink-crypto/tink-go/v2/subtle/random" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type aesEncryptionService struct { cipher cipher.AEAD keyID string store store.Store clients []types.EncryptionClient } func (svc *aesEncryptionService) Encrypt(plaintext, associatedData string) (string, error) { msg := []byte(plaintext) aad := []byte(associatedData) nonce := random.GetRandomBytes(uint32(AES_GCM_SIV_NonceSize)) ciphertext := svc.cipher.Seal(nil, nonce, msg, aad) result := make([]byte, 0, AES_GCM_SIV_NonceSize+len(ciphertext)) result = append(result, nonce...) result = append(result, ciphertext...) return base64.StdEncoding.EncodeToString(result), nil } func (svc *aesEncryptionService) Decrypt(ciphertext, associatedData string) (string, error) { bytes, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return "", fmt.Errorf(errTemplateBase64DecryptionFailed, err) } nonce := bytes[:AES_GCM_SIV_NonceSize] message := bytes[AES_GCM_SIV_NonceSize:] plaintext, err := svc.cipher.Open(nil, nonce, message, []byte(associatedData)) if err != nil { return "", fmt.Errorf(errTemplateDecryptionFailed, err) } return string(plaintext), nil } func (svc *aesEncryptionService) Disable() error { return svc.disable() } ================================================ FILE: server/services/encryption/aes_builder.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "errors" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type aesConfiguration struct { password string store store.Store clients []types.EncryptionClient } func newAES(c *cli.Command, s store.Store) types.EncryptionServiceBuilder { key := c.String(rawKeyConfigFlag) return &aesConfiguration{key, s, nil} } func (c aesConfiguration) WithClients(clients []types.EncryptionClient) types.EncryptionServiceBuilder { c.clients = clients return c } func (c aesConfiguration) Build() (types.EncryptionService, error) { svc := &aesEncryptionService{ cipher: nil, store: c.store, clients: c.clients, } err := svc.initClients() if err != nil { return nil, fmt.Errorf(errTemplateFailedInitializingClients, err) } err = svc.loadCipher(c.password) if err != nil { return nil, fmt.Errorf(errTemplateAesFailedLoadingCipher, err) } err = svc.validateKey() if errors.Is(err, errEncryptionNotEnabled) { err = svc.enable() } if err != nil { return nil, fmt.Errorf(errTemplateFailedValidatingKey, err) } return svc, nil } ================================================ FILE: server/services/encryption/aes_encryption.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "crypto/aes" "crypto/cipher" "errors" "fmt" "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/sha3" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (svc *aesEncryptionService) loadCipher(password string) error { key, err := svc.hash([]byte(password)) if err != nil { return fmt.Errorf(errTemplateAesFailedGeneratingKey, err) } keyHash, err := bcrypt.GenerateFromPassword(key, bcrypt.DefaultCost) if err != nil { return fmt.Errorf(errTemplateAesFailedGeneratingKeyID, err) } svc.keyID = string(keyHash) block, err := aes.NewCipher(key) if err != nil { return fmt.Errorf(errTemplateAesFailedLoadingCipher, err) } aead, err := cipher.NewGCM(block) if err != nil { return fmt.Errorf(errTemplateAesFailedLoadingCipher, err) } svc.cipher = aead return nil } func (svc *aesEncryptionService) validateKey() error { ciphertextSample, err := svc.store.ServerConfigGet(ciphertextSampleConfigKey) if errors.Is(err, types.ErrRecordNotExist) { return errEncryptionNotEnabled } else if err != nil { return fmt.Errorf(errTemplateFailedLoadingServerConfig, err) } plaintext, err := svc.Decrypt(ciphertextSample, keyIDAssociatedData) if plaintext != svc.keyID { return errEncryptionKeyInvalid } else if err != nil { return err } return nil } func (svc *aesEncryptionService) hash(data []byte) ([]byte, error) { result := make([]byte, 32) sha := sha3.NewShake256() _, err := sha.Write(data) if err != nil { return nil, fmt.Errorf(errTemplateAesFailedCalculatingHash, err) } _, err = sha.Read(result) if err != nil { return nil, fmt.Errorf(errTemplateAesFailedCalculatingHash, err) } return result, nil } ================================================ FILE: server/services/encryption/aes_state.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "fmt" "github.com/rs/zerolog/log" ) func (svc *aesEncryptionService) initClients() error { for _, client := range svc.clients { err := client.SetEncryptionService(svc) if err != nil { return fmt.Errorf(errTemplateFailedInitializingClients, err) } } log.Info().Msg(logMessageClientsInitialized) return nil } func (svc *aesEncryptionService) enable() error { err := svc.callbackOnEnable() if err != nil { return fmt.Errorf(errTemplateFailedEnablingEncryption, err) } err = svc.updateCiphertextSample() if err != nil { return fmt.Errorf(errTemplateFailedEnablingEncryption, err) } log.Warn().Msg(logMessageEncryptionEnabled) return nil } func (svc *aesEncryptionService) disable() error { err := svc.callbackOnDisable() if err != nil { return fmt.Errorf(errTemplateFailedDisablingEncryption, err) } err = svc.deleteCiphertextSample() if err != nil { return fmt.Errorf(errTemplateFailedDisablingEncryption, err) } log.Warn().Msg(logMessageEncryptionDisabled) return nil } func (svc *aesEncryptionService) updateCiphertextSample() error { ciphertext, err := svc.Encrypt(svc.keyID, keyIDAssociatedData) if err != nil { return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) } err = svc.store.ServerConfigSet(ciphertextSampleConfigKey, ciphertext) if err != nil { return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) } log.Info().Msg(logMessageEncryptionKeyRegistered) return nil } func (svc *aesEncryptionService) deleteCiphertextSample() error { err := svc.store.ServerConfigDelete(ciphertextSampleConfigKey) if err != nil { err = fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) } return err } func (svc *aesEncryptionService) callbackOnEnable() error { for _, client := range svc.clients { err := client.EnableEncryption() if err != nil { return fmt.Errorf(errTemplateFailedEnablingEncryption, err) } } log.Info().Msg(logMessageClientsEnabled) return nil } func (svc *aesEncryptionService) callbackOnDisable() error { for _, client := range svc.clients { err := client.MigrateEncryption(&noEncryption{}) if err != nil { return fmt.Errorf(errTemplateFailedDisablingEncryption, err) } } log.Info().Msg(logMessageEncryptionDisabled) return nil } ================================================ FILE: server/services/encryption/aes_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "encoding/base64" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tink-crypto/tink-go/v2/subtle/random" ) func TestShortMessageLongKey(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(32))) assert.NoError(t, err) input := string(random.GetRandomBytes(4)) cipher, err := aes.Encrypt(input, "") assert.NoError(t, err) output, err := aes.Decrypt(cipher, "") assert.NoError(t, err) assert.Equal(t, input, output) } func TestLongMessageShortKey(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(12))) assert.NoError(t, err) input := string(random.GetRandomBytes(1024)) cipher, err := aes.Encrypt(input, "") assert.NoError(t, err) output, err := aes.Decrypt(cipher, "") assert.NoError(t, err) assert.Equal(t, input, output) } func TestEncryptDecryptWithAssociatedData(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(32))) require.NoError(t, err) plaintext := "secret-value-12345" associatedData := "repo:123" ciphertext, err := aes.Encrypt(plaintext, associatedData) require.NoError(t, err) // Decrypt with correct associated data should succeed decrypted, err := aes.Decrypt(ciphertext, associatedData) require.NoError(t, err) assert.Equal(t, plaintext, decrypted) // Decrypt with wrong associated data should fail _, err = aes.Decrypt(ciphertext, "repo:456") assert.Error(t, err, "decryption should fail with wrong associated data") // Decrypt with empty associated data should fail _, err = aes.Decrypt(ciphertext, "") assert.Error(t, err, "decryption should fail with missing associated data") } func TestEncryptProducesUniqueCiphertexts(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(32))) require.NoError(t, err) plaintext := "same-message" ciphertexts := make(map[string]bool) // Encrypt the same message multiple times for range 100 { ct, err := aes.Encrypt(plaintext, "") require.NoError(t, err) assert.False(t, ciphertexts[ct], "ciphertext should be unique due to random nonce") ciphertexts[ct] = true } } func TestDecryptTamperedCiphertext(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(32))) require.NoError(t, err) plaintext := "sensitive-data" ciphertext, err := aes.Encrypt(plaintext, "") require.NoError(t, err) // Decode, tamper, re-encode decoded, err := base64.StdEncoding.DecodeString(ciphertext) require.NoError(t, err) // Tamper with the ciphertext (flip a bit in the middle) if len(decoded) > AES_GCM_SIV_NonceSize+1 { decoded[AES_GCM_SIV_NonceSize+1] ^= 0xFF } tampered := base64.StdEncoding.EncodeToString(decoded) _, err = aes.Decrypt(tampered, "") assert.Error(t, err, "decryption of tampered ciphertext should fail") } func TestDecryptInvalidBase64(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(32))) require.NoError(t, err) _, err = aes.Decrypt("not-valid-base64!!!", "") assert.Error(t, err, "decryption of invalid base64 should fail") } func TestDecryptTruncatedCiphertext(t *testing.T) { aes := &aesEncryptionService{} err := aes.loadCipher(string(random.GetRandomBytes(32))) require.NoError(t, err) plaintext := "test-message" ciphertext, err := aes.Encrypt(plaintext, "") require.NoError(t, err) // Truncate the ciphertext decoded, err := base64.StdEncoding.DecodeString(ciphertext) require.NoError(t, err) truncated := base64.StdEncoding.EncodeToString(decoded[:len(decoded)/2]) _, err = aes.Decrypt(truncated, "") assert.Error(t, err, "decryption of truncated ciphertext should fail") } func TestRandomBytesUniqueness(t *testing.T) { seen := make(map[string]bool) for range 1000 { bytes := random.GetRandomBytes(32) key := string(bytes) assert.False(t, seen[key], "random bytes should be unique") seen[key] = true } } func TestRandomBytesLength(t *testing.T) { tests := []uint32{1, 12, 16, 32, 64, 128, 256} for _, length := range tests { bytes := random.GetRandomBytes(length) assert.Len(t, bytes, int(length), "random bytes should have requested length") } } ================================================ FILE: server/services/encryption/constants.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import "errors" // Common. const ( rawKeyConfigFlag = "encryption-raw-key" tinkKeysetFilepathConfigFlag = "encryption-tink-keyset" disableEncryptionConfigFlag = "encryption-disable-flag" ciphertextSampleConfigKey = "encryption-ciphertext-sample" keyTypeTink = "tink" keyTypeRaw = "raw" keyTypeNone = "none" keyIDAssociatedData = "Primary key id" AES_GCM_SIV_NonceSize = 12 //nolint:revive ) var ( errEncryptionNotEnabled = errors.New("encryption is not enabled") errEncryptionKeyInvalid = errors.New("encryption key is invalid") errEncryptionKeyRotated = errors.New("encryption key is being rotated") ) const ( // Error wrapping templates. errTemplateFailedInitializingUnencrypted = "failed initializing server in unencrypted mode: %w" errTemplateFailedInitializing = "failed initializing encryption service: %w" errTemplateFailedEnablingEncryption = "failed enabling encryption: %w" errTemplateFailedRotatingEncryption = "failed rotating encryption: %w" errTemplateFailedDisablingEncryption = "failed disabling encryption: %w" errTemplateFailedLoadingServerConfig = "failed to load server encryption config: %w" errTemplateFailedUpdatingServerConfig = "failed updating server encryption configuration: %w" errTemplateFailedInitializingClients = "failed initializing encryption clients: %w" errTemplateFailedValidatingKey = "failed validating encryption key: %w" errTemplateEncryptionFailed = "encryption error: %w" errTemplateBase64DecryptionFailed = "decryption error: Base64 decryption failed. Cause: %w" errTemplateDecryptionFailed = "decryption error: %w" // Error messages. errMessageTemplateUnsupportedKeyType = "unsupported encryption key type: %s" errMessageCantUseBothServices = "cannot use raw encryption key and tink keyset at the same time" errMessageNoKeysProvided = "encryption enabled but no keys provided" errMessageFailedRotatingEncryption = "failed rotating encryption" // Log messages. logMessageEncryptionEnabled = "encryption enabled" logMessageEncryptionDisabled = "encryption disabled" logMessageEncryptionKeyRegistered = "registered new encryption key" logMessageClientsInitialized = "initialized encryption on registered clients" logMessageClientsEnabled = "enabled encryption on registered service" logMessageClientsRotated = "updated encryption key on registered service" logMessageClientsDecrypted = "disabled encryption on registered service" ) // Tink. const ( // Error wrapping templates. errTemplateTinkFailedLoadingKeyset = "failed loading encryption keyset: %w" errTemplateTinkFailedValidatingKeyset = "failed validating encryption keyset: %w" errTemplateTinkFailedInitializeFileWatcher = "failed initializing keyset file watcher: %w" errTemplateTinkFailedSubscribeKeysetFileChanges = "failed subscribing on encryption keyset file changes: %w" errTemplateTinkFailedOpeningKeyset = "failed opening encryption keyset file: %w" errTemplateTinkFailedReadingKeyset = "failed reading encryption keyset from file: %w" errTemplateTinkFailedInitializingAEAD = "failed initializing AEAD instance: %w" // Error messages. errMessageTinkKeysetFileWatchFailed = "failed watching encryption keyset file changes" // Log message templates. logTemplateTinkKeysetFileChanged = "changes detected in encryption keyset file: '%s'. Encryption service will be reloaded" logTemplateTinkLoadingKeyset = "loading encryption keyset from file: %s" logTemplateTinkFailedClosingKeysetFile = "could not close keyset file: %s" ) // AES. const ( // Error wrapping templates. errTemplateAesFailedLoadingCipher = "failed loading encryption cipher: %w" errTemplateAesFailedCalculatingHash = "failed calculating hash: %w" errTemplateAesFailedGeneratingKey = "failed generating key from passphrase: %w" errTemplateAesFailedGeneratingKeyID = "failed generating key id: %w" ) ================================================ FILE: server/services/encryption/encryption.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type builder struct { store store.Store c *cli.Command clients []types.EncryptionClient } func Encryption(c *cli.Command, s store.Store) types.EncryptionBuilder { return &builder{store: s, c: c} } func (b builder) WithClient(client types.EncryptionClient) types.EncryptionBuilder { b.clients = append(b.clients, client) return b } func (b builder) Build() error { enabled, err := b.isEnabled() if err != nil { return err } disableFlag := b.c.Bool(disableEncryptionConfigFlag) keyType, err := b.detectKeyType() if err != nil { return err } if !enabled && (disableFlag || keyType == keyTypeNone) { _, err := noEncryptionBuilder{}.WithClients(b.clients).Build() if err != nil { return fmt.Errorf(errTemplateFailedInitializingUnencrypted, err) } } svc, err := b.getService(keyType) if err != nil { return fmt.Errorf(errTemplateFailedInitializing, err) } if disableFlag { err := svc.Disable() if err != nil { return err } } return nil } ================================================ FILE: server/services/encryption/encryption_builder.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "errors" "fmt" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" store_types "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (b builder) getService(keyType string) (types.EncryptionService, error) { if keyType == keyTypeNone { return nil, errors.New(errMessageNoKeysProvided) } builder, err := b.serviceBuilder(keyType) if err != nil { return nil, err } svc, err := builder.WithClients(b.clients).Build() if err != nil { return nil, err } return svc, nil } func (b builder) isEnabled() (bool, error) { _, err := b.store.ServerConfigGet(ciphertextSampleConfigKey) if err != nil && !errors.Is(err, store_types.ErrRecordNotExist) { return false, fmt.Errorf(errTemplateFailedLoadingServerConfig, err) } return err == nil, nil } func (b builder) detectKeyType() (string, error) { rawKeyPresent := b.c.IsSet(rawKeyConfigFlag) tinkKeysetPresent := b.c.IsSet(tinkKeysetFilepathConfigFlag) switch { case rawKeyPresent && tinkKeysetPresent: return "", errors.New(errMessageCantUseBothServices) case rawKeyPresent: return keyTypeRaw, nil case tinkKeysetPresent: return keyTypeTink, nil } return keyTypeNone, nil } func (b builder) serviceBuilder(keyType string) (types.EncryptionServiceBuilder, error) { switch keyType { case keyTypeTink: return newTink(b.c, b.store), nil case keyTypeRaw: return newAES(b.c, b.store), nil case keyTypeNone: return &noEncryptionBuilder{}, nil } return nil, fmt.Errorf(errMessageTemplateUnsupportedKeyType, keyType) } ================================================ FILE: server/services/encryption/no_encryption.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" type noEncryptionBuilder struct { clients []types.EncryptionClient } func (b noEncryptionBuilder) WithClients(clients []types.EncryptionClient) types.EncryptionServiceBuilder { b.clients = clients return b } func (b noEncryptionBuilder) Build() (types.EncryptionService, error) { svc := &noEncryption{} for _, client := range b.clients { err := client.SetEncryptionService(svc) if err != nil { return nil, err } } return svc, nil } type noEncryption struct{} func (svc *noEncryption) Encrypt(plaintext, _ string) (string, error) { return plaintext, nil } func (svc *noEncryption) Decrypt(ciphertext, _ string) (string, error) { return ciphertext, nil } func (svc *noEncryption) Disable() error { return nil } ================================================ FILE: server/services/encryption/tink.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "encoding/base64" "fmt" "github.com/fsnotify/fsnotify" "github.com/tink-crypto/tink-go/v2/tink" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type tinkEncryptionService struct { keysetFilePath string primaryKeyID string encryption tink.AEAD store store.Store keysetFileWatcher *fsnotify.Watcher clients []types.EncryptionClient } func (svc *tinkEncryptionService) Encrypt(plaintext, associatedData string) (string, error) { msg := []byte(plaintext) aad := []byte(associatedData) ciphertext, err := svc.encryption.Encrypt(msg, aad) if err != nil { return "", fmt.Errorf(errTemplateEncryptionFailed, err) } return base64.StdEncoding.EncodeToString(ciphertext), nil } func (svc *tinkEncryptionService) Decrypt(ciphertext, associatedData string) (string, error) { ct, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return "", fmt.Errorf(errTemplateBase64DecryptionFailed, err) } plaintext, err := svc.encryption.Decrypt(ct, []byte(associatedData)) if err != nil { return "", fmt.Errorf(errTemplateDecryptionFailed, err) } return string(plaintext), nil } func (svc *tinkEncryptionService) Disable() error { return svc.disable() } ================================================ FILE: server/services/encryption/tink_builder.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "errors" "fmt" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type tinkConfiguration struct { keysetFilePath string store store.Store clients []types.EncryptionClient } func newTink(c *cli.Command, s store.Store) types.EncryptionServiceBuilder { filepath := c.String(tinkKeysetFilepathConfigFlag) return &tinkConfiguration{filepath, s, nil} } func (c tinkConfiguration) WithClients(clients []types.EncryptionClient) types.EncryptionServiceBuilder { c.clients = clients return c } func (c tinkConfiguration) Build() (types.EncryptionService, error) { svc := &tinkEncryptionService{ keysetFilePath: c.keysetFilePath, primaryKeyID: "", encryption: nil, store: c.store, keysetFileWatcher: nil, clients: c.clients, } if err := svc.initClients(); err != nil { return nil, fmt.Errorf(errTemplateFailedInitializingClients, err) } if err := svc.loadKeyset(); err != nil { return nil, fmt.Errorf(errTemplateTinkFailedLoadingKeyset, err) } err := svc.validateKeyset() if errors.Is(err, errEncryptionNotEnabled) { err = svc.enable() } else if errors.Is(err, errEncryptionKeyRotated) { err = svc.rotate() } if err != nil { return nil, fmt.Errorf(errTemplateTinkFailedValidatingKeyset, err) } if err := svc.initFileWatcher(); err != nil { return nil, fmt.Errorf(errTemplateTinkFailedInitializeFileWatcher, err) } return svc, nil } ================================================ FILE: server/services/encryption/tink_keyset.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "errors" "fmt" "os" "strconv" "github.com/rs/zerolog/log" "github.com/tink-crypto/tink-go/v2/aead" insecure_clear_text_keyset "github.com/tink-crypto/tink-go/v2/insecurecleartextkeyset" "github.com/tink-crypto/tink-go/v2/keyset" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (svc *tinkEncryptionService) loadKeyset() error { log.Warn().Msgf(logTemplateTinkLoadingKeyset, svc.keysetFilePath) file, err := os.Open(svc.keysetFilePath) if err != nil { return fmt.Errorf(errTemplateTinkFailedOpeningKeyset, err) } defer func(file *os.File) { err = file.Close() if err != nil { log.Error().Err(err).Msgf(logTemplateTinkFailedClosingKeysetFile, svc.keysetFilePath) } }(file) jsonKeyset := keyset.NewJSONReader(file) keysetHandle, err := insecure_clear_text_keyset.Read(jsonKeyset) if err != nil { return fmt.Errorf(errTemplateTinkFailedReadingKeyset, err) } svc.primaryKeyID = strconv.FormatUint(uint64(keysetHandle.KeysetInfo().PrimaryKeyId), 10) encryptionInstance, err := aead.New(keysetHandle) if err != nil { return fmt.Errorf(errTemplateTinkFailedInitializingAEAD, err) } svc.encryption = encryptionInstance return nil } func (svc *tinkEncryptionService) validateKeyset() error { ciphertextSample, err := svc.store.ServerConfigGet(ciphertextSampleConfigKey) if errors.Is(err, types.ErrRecordNotExist) { return errEncryptionNotEnabled } else if err != nil { return fmt.Errorf(errTemplateFailedLoadingServerConfig, err) } plaintext, err := svc.Decrypt(ciphertextSample, keyIDAssociatedData) if plaintext != svc.primaryKeyID { return errEncryptionKeyRotated } else if err != nil { return fmt.Errorf(errTemplateFailedValidatingKey, err) } return nil } ================================================ FILE: server/services/encryption/tink_keyset_watcher.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "fmt" "github.com/fsnotify/fsnotify" "github.com/rs/zerolog/log" ) // Watch keyset file events to detect key rotations and hot reload keys. func (svc *tinkEncryptionService) initFileWatcher() error { watcher, err := fsnotify.NewWatcher() if err != nil { return fmt.Errorf(errTemplateTinkFailedSubscribeKeysetFileChanges, err) } err = watcher.Add(svc.keysetFilePath) if err != nil { return fmt.Errorf(errTemplateTinkFailedSubscribeKeysetFileChanges, err) } svc.keysetFileWatcher = watcher go svc.handleFileEvents() return nil } func (svc *tinkEncryptionService) handleFileEvents() { for { select { case event, ok := <-svc.keysetFileWatcher.Events: if !ok { log.Fatal().Msg(errMessageTinkKeysetFileWatchFailed) //nolint:forbidigo } if (event.Op == fsnotify.Write) || (event.Op == fsnotify.Create) { log.Warn().Msgf(logTemplateTinkKeysetFileChanged, event.Name) err := svc.rotate() if err != nil { log.Fatal().Err(err).Msg(errMessageFailedRotatingEncryption) //nolint:forbidigo } return } case err, ok := <-svc.keysetFileWatcher.Errors: if !ok { log.Fatal().Err(err).Msg(errMessageTinkKeysetFileWatchFailed) //nolint:forbidigo } } } } ================================================ FILE: server/services/encryption/tink_state.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package encryption import ( "errors" "fmt" "github.com/rs/zerolog/log" ) func (svc *tinkEncryptionService) enable() error { if err := svc.callbackOnEnable(); err != nil { return fmt.Errorf(errTemplateFailedEnablingEncryption, err) } if err := svc.updateCiphertextSample(); err != nil { return fmt.Errorf(errTemplateFailedEnablingEncryption, err) } log.Warn().Msg(logMessageEncryptionEnabled) return nil } func (svc *tinkEncryptionService) disable() error { if err := svc.callbackOnDisable(); err != nil { return fmt.Errorf(errTemplateFailedDisablingEncryption, err) } if err := svc.deleteCiphertextSample(); err != nil { return fmt.Errorf(errTemplateFailedDisablingEncryption, err) } log.Warn().Msg(logMessageEncryptionDisabled) return nil } func (svc *tinkEncryptionService) rotate() error { newSvc := &tinkEncryptionService{ keysetFilePath: svc.keysetFilePath, primaryKeyID: "", encryption: nil, store: svc.store, keysetFileWatcher: nil, clients: svc.clients, } if err := newSvc.loadKeyset(); err != nil { return fmt.Errorf(errTemplateFailedRotatingEncryption, err) } err := newSvc.validateKeyset() if errors.Is(err, errEncryptionKeyRotated) { err = newSvc.updateCiphertextSample() } if err != nil { return fmt.Errorf(errTemplateFailedRotatingEncryption, err) } if err := newSvc.callbackOnRotation(); err != nil { return fmt.Errorf(errTemplateFailedRotatingEncryption, err) } if err := newSvc.initFileWatcher(); err != nil { return fmt.Errorf(errTemplateFailedRotatingEncryption, err) } return nil } func (svc *tinkEncryptionService) updateCiphertextSample() error { ciphertext, err := svc.Encrypt(svc.primaryKeyID, keyIDAssociatedData) if err != nil { return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) } if err := svc.store.ServerConfigSet(ciphertextSampleConfigKey, ciphertext); err != nil { return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) } log.Info().Msg(logMessageEncryptionKeyRegistered) return nil } func (svc *tinkEncryptionService) deleteCiphertextSample() error { if err := svc.store.ServerConfigDelete(ciphertextSampleConfigKey); err != nil { return fmt.Errorf(errTemplateFailedUpdatingServerConfig, err) } return nil } func (svc *tinkEncryptionService) initClients() error { for _, client := range svc.clients { if err := client.SetEncryptionService(svc); err != nil { return err } } log.Info().Msg(logMessageClientsInitialized) return nil } func (svc *tinkEncryptionService) callbackOnEnable() error { for _, client := range svc.clients { if err := client.EnableEncryption(); err != nil { return err } } log.Info().Msg(logMessageClientsEnabled) return nil } func (svc *tinkEncryptionService) callbackOnRotation() error { for _, client := range svc.clients { if err := client.MigrateEncryption(svc); err != nil { return err } } log.Info().Msg(logMessageClientsRotated) return nil } func (svc *tinkEncryptionService) callbackOnDisable() error { for _, client := range svc.clients { if err := client.MigrateEncryption(&noEncryption{}); err != nil { return err } } log.Info().Msg(logMessageClientsDecrypted) return nil } ================================================ FILE: server/services/encryption/types/encryption.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types // EncryptionBuilder is user API to obtain correctly configured encryption. type EncryptionBuilder interface { WithClient(client EncryptionClient) EncryptionBuilder Build() error } type EncryptionServiceBuilder interface { WithClients(clients []EncryptionClient) EncryptionServiceBuilder Build() (EncryptionService, error) } type EncryptionService interface { Encrypt(plaintext, associatedData string) (string, error) Decrypt(ciphertext, associatedData string) (string, error) Disable() error } type EncryptionClient interface { // SetEncryptionService should be used only by EncryptionServiceBuilder SetEncryptionService(encryption EncryptionService) error // EnableEncryption should encrypt all service data EnableEncryption() error // MigrateEncryption should decrypt all existing data and encrypt it with new encryption service MigrateEncryption(newEncryption EncryptionService) error } ================================================ FILE: server/services/encryption/wrapper/store/constants.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store const ( errMessageTemplateFailedToEnable = "failed enabling secret store encryption: %w" errMessageTemplateFailedToMigrate = "failed migrating secret store encryption: %w" errMessageTemplateFailedToEncryptSecret = "failed to encrypt secret id=%d: %w" errMessageTemplateFailedToDecryptSecret = "failed to decrypt secret id=%d: %w" errMessageTemplateStorageError = "Storage error: could not update secret in DB" errMessageTemplateFailedToRollbackSecretCreation = "failed creating secret: %w. Also failed deleting temporary secret record from store: %s" errMessageInitSeveralTimes = "attempt to init encrypted storage more than once" logMessageEnablingSecretsEncryption = "Encrypting all secrets in database" logMessageEnablingSecretsEncryptionSuccess = "All secrets are encrypted" logMessageMigratingSecretsEncryption = "Migrating encryption keys" logMessageMigratingSecretsEncryptionSuccess = "Secrets encryption migrated successfully" ) ================================================ FILE: server/services/encryption/wrapper/store/secret_store.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import ( "fmt" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (wrapper *EncryptedSecretStore) SecretFind(repo *model.Repo, s string) (*model.Secret, error) { result, err := wrapper.store.SecretFind(repo, s) if err != nil { return nil, err } err = wrapper.decrypt(result) if err != nil { return nil, err } return result, nil } func (wrapper *EncryptedSecretStore) SecretList(repo *model.Repo, b bool, p *model.ListOptions) ([]*model.Secret, error) { results, err := wrapper.store.SecretList(repo, b, p) if err != nil { return nil, err } err = wrapper.decryptList(results) if err != nil { return nil, err } return results, nil } func (wrapper *EncryptedSecretStore) SecretCreate(secret *model.Secret) error { newSecret := &model.Secret{} err := wrapper.store.SecretCreate(newSecret) if err != nil { return err } secret.ID = newSecret.ID err = wrapper.encrypt(secret) if err != nil { deleteErr := wrapper.store.SecretDelete(newSecret) if deleteErr != nil { return fmt.Errorf(errMessageTemplateFailedToRollbackSecretCreation, err, deleteErr.Error()) } return err } err = wrapper.store.SecretUpdate(secret) if err != nil { deleteErr := wrapper.store.SecretDelete(newSecret) if deleteErr != nil { return fmt.Errorf(errMessageTemplateFailedToRollbackSecretCreation, err, deleteErr.Error()) } return err } err = wrapper.decrypt(secret) if err != nil { return err } return nil } func (wrapper *EncryptedSecretStore) SecretUpdate(secret *model.Secret) error { err := wrapper.encrypt(secret) if err != nil { return err } err = wrapper.store.SecretUpdate(secret) if err != nil { return err } err = wrapper.decrypt(secret) if err != nil { return err } return nil } func (wrapper *EncryptedSecretStore) SecretDelete(secret *model.Secret) error { return wrapper.store.SecretDelete(secret) } func (wrapper *EncryptedSecretStore) OrgSecretFind(s int64, s2 string) (*model.Secret, error) { result, err := wrapper.store.OrgSecretFind(s, s2) if err != nil { return nil, err } err = wrapper.decrypt(result) if err != nil { return nil, err } return result, nil } func (wrapper *EncryptedSecretStore) OrgSecretList(s int64, p *model.ListOptions) ([]*model.Secret, error) { results, err := wrapper.store.OrgSecretList(s, p) if err != nil { return nil, err } err = wrapper.decryptList(results) if err != nil { return nil, err } return results, nil } func (wrapper *EncryptedSecretStore) GlobalSecretFind(s string) (*model.Secret, error) { result, err := wrapper.store.GlobalSecretFind(s) if err != nil { return nil, err } err = wrapper.decrypt(result) if err != nil { return nil, err } return result, nil } func (wrapper *EncryptedSecretStore) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) { results, err := wrapper.store.GlobalSecretList(p) if err != nil { return nil, err } err = wrapper.decryptList(results) if err != nil { return nil, err } return results, nil } func (wrapper *EncryptedSecretStore) SecretListAll() ([]*model.Secret, error) { results, err := wrapper.store.SecretListAll() if err != nil { return nil, err } err = wrapper.decryptList(results) if err != nil { return nil, err } return results, nil } ================================================ FILE: server/services/encryption/wrapper/store/secret_store_wrapper.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import ( "errors" "fmt" "strconv" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/encryption/types" ) type EncryptedSecretStore struct { store model.SecretStore encryption types.EncryptionService } // Ensure wrapper match interface. var _ model.SecretStore = new(EncryptedSecretStore) func NewSecretStore(secretStore model.SecretStore) *EncryptedSecretStore { wrapper := EncryptedSecretStore{secretStore, nil} return &wrapper } func (wrapper *EncryptedSecretStore) SetEncryptionService(service types.EncryptionService) error { if wrapper.encryption != nil { return errors.New(errMessageInitSeveralTimes) } wrapper.encryption = service return nil } func (wrapper *EncryptedSecretStore) EnableEncryption() error { log.Warn().Msg(logMessageEnablingSecretsEncryption) secrets, err := wrapper.store.SecretListAll() if err != nil { return fmt.Errorf(errMessageTemplateFailedToEnable, err) } for _, secret := range secrets { if err := wrapper.encrypt(secret); err != nil { return err } if err := wrapper._save(secret); err != nil { return err } } log.Warn().Msg(logMessageEnablingSecretsEncryptionSuccess) return nil } func (wrapper *EncryptedSecretStore) MigrateEncryption(newEncryptionService types.EncryptionService) error { log.Warn().Msg(logMessageMigratingSecretsEncryption) secrets, err := wrapper.store.SecretListAll() if err != nil { return fmt.Errorf(errMessageTemplateFailedToMigrate, err) } if err := wrapper.decryptList(secrets); err != nil { return err } wrapper.encryption = newEncryptionService for _, secret := range secrets { if err := wrapper.encrypt(secret); err != nil { return err } if err := wrapper._save(secret); err != nil { return err } } log.Warn().Msg(logMessageMigratingSecretsEncryptionSuccess) return nil } func (wrapper *EncryptedSecretStore) encrypt(secret *model.Secret) error { encryptedValue, err := wrapper.encryption.Encrypt(secret.Value, strconv.Itoa(int(secret.ID))) if err != nil { return fmt.Errorf(errMessageTemplateFailedToEncryptSecret, secret.ID, err) } secret.Value = encryptedValue return nil } func (wrapper *EncryptedSecretStore) decrypt(secret *model.Secret) error { decryptedValue, err := wrapper.encryption.Decrypt(secret.Value, strconv.Itoa(int(secret.ID))) if err != nil { return fmt.Errorf(errMessageTemplateFailedToDecryptSecret, secret.ID, err) } secret.Value = decryptedValue return nil } func (wrapper *EncryptedSecretStore) decryptList(secrets []*model.Secret) error { for _, secret := range secrets { err := wrapper.decrypt(secret) if err != nil { return fmt.Errorf(errMessageTemplateFailedToDecryptSecret, secret.ID, err) } } return nil } func (wrapper *EncryptedSecretStore) _save(secret *model.Secret) error { err := wrapper.store.SecretUpdate(secret) if err != nil { log.Err(err).Msg(errMessageTemplateStorageError) return err } return nil } ================================================ FILE: server/services/environment/mocks/mock_Service.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockService(t interface { mock.TestingT Cleanup(func()) }) *MockService { mock := &MockService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockService is an autogenerated mock type for the Service type type MockService struct { mock.Mock } type MockService_Expecter struct { mock *mock.Mock } func (_m *MockService) EXPECT() *MockService_Expecter { return &MockService_Expecter{mock: &_m.Mock} } // EnvironList provides a mock function for the type MockService func (_mock *MockService) EnvironList(repo *model.Repo) ([]*model.Environ, error) { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for EnvironList") } var r0 []*model.Environ var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo) ([]*model.Environ, error)); ok { return returnFunc(repo) } if returnFunc, ok := ret.Get(0).(func(*model.Repo) []*model.Environ); ok { r0 = returnFunc(repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Environ) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo) error); ok { r1 = returnFunc(repo) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_EnvironList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvironList' type MockService_EnvironList_Call struct { *mock.Call } // EnvironList is a helper method to define mock.On call // - repo *model.Repo func (_e *MockService_Expecter) EnvironList(repo interface{}) *MockService_EnvironList_Call { return &MockService_EnvironList_Call{Call: _e.mock.On("EnvironList", repo)} } func (_c *MockService_EnvironList_Call) Run(run func(repo *model.Repo)) *MockService_EnvironList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockService_EnvironList_Call) Return(environs []*model.Environ, err error) *MockService_EnvironList_Call { _c.Call.Return(environs, err) return _c } func (_c *MockService_EnvironList_Call) RunAndReturn(run func(repo *model.Repo) ([]*model.Environ, error)) *MockService_EnvironList_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/services/environment/parse.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package environment import ( "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type builtin struct { globals []*model.Environ } // Parse returns a Service based on a string slice where key and value are separated by a ":" delimiter. func Parse(params []string) Service { var globals []*model.Environ for _, item := range params { before, after, _ := strings.Cut(item, ":") if after == "" { // ignore items only containing a key and no value log.Warn().Msgf("key '%s' has no value, will be ignored", before) continue } globals = append(globals, &model.Environ{Name: before, Value: after}) } return &builtin{globals} } func (b *builtin) EnvironList(_ *model.Repo) ([]*model.Environ, error) { return b.globals, nil } ================================================ FILE: server/services/environment/parse_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package environment import ( "testing" "github.com/stretchr/testify/assert" ) func TestParse(t *testing.T) { service := Parse([]string{}) env, err := service.EnvironList(nil) assert.NoError(t, err) assert.Empty(t, env) service = Parse([]string{"ENV:value"}) env, err = service.EnvironList(nil) assert.NoError(t, err) assert.Len(t, env, 1) assert.Equal(t, env[0].Name, "ENV") assert.Equal(t, env[0].Value, "value") service = Parse([]string{"ENV:value", "ENV2:value2"}) env, err = service.EnvironList(nil) assert.NoError(t, err) assert.Len(t, env, 2) service = Parse([]string{"ENV:value", "ENV2:value2", "ENV3_WITHOUT_VALUE"}) env, err = service.EnvironList(nil) assert.NoError(t, err) assert.Len(t, env, 2) } ================================================ FILE: server/services/environment/service.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package environment import "go.woodpecker-ci.org/woodpecker/v3/server/model" // Service defines a service for managing environment variables. type Service interface { EnvironList(*model.Repo) ([]*model.Environ, error) } ================================================ FILE: server/services/log/addon/client.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "encoding/json" "net/rpc" "os/exec" "github.com/hashicorp/go-plugin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" service_log "go.woodpecker-ci.org/woodpecker/v3/server/services/log" "go.woodpecker-ci.org/woodpecker/v3/shared/logger" ) // make sure RPC implements service_log.Service. var _ service_log.Service = new(RPC) func Load(file string) (service_log.Service, error) { client := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: HandshakeConfig, Plugins: map[string]plugin.Plugin{ pluginKey: &Plugin{}, }, Cmd: exec.Command(file), Logger: &logger.AddonClientLogger{ Logger: log.With().Str("addon", file).Logger(), }, }) // TODO: defer client.Kill() rpcClient, err := client.Client() if err != nil { return nil, err } raw, err := rpcClient.Dispense(pluginKey) if err != nil { return nil, err } extension, _ := raw.(service_log.Service) return extension, nil } type RPC struct { client *rpc.Client } func (g *RPC) LogFind(step *model.Step) ([]*model.LogEntry, error) { args, err := json.Marshal(step) if err != nil { return nil, err } var jsonResp []byte err = g.client.Call("Plugin.LogFind", args, &jsonResp) if err != nil { return nil, err } var resp []*model.LogEntry err = json.Unmarshal(jsonResp, &resp) if err != nil { return nil, err } return resp, nil } func (g *RPC) LogAppend(step *model.Step, logEntries []*model.LogEntry) error { args, err := json.Marshal(&argumentsAppend{ Step: step, LogEntries: logEntries, }) if err != nil { return err } var jsonResp []byte return g.client.Call("Plugin.LogAppend", args, &jsonResp) } func (g *RPC) LogDelete(step *model.Step) error { args, err := json.Marshal(step) if err != nil { return err } var jsonResp []byte return g.client.Call("Plugin.LogDelete", args, &jsonResp) } func (g *RPC) StepFinished(step *model.Step) { args, err := json.Marshal(step) if err != nil { log.Error().Err(err).Msg("could not marshal json for log addon") return } var jsonResp []byte err = g.client.Call("Plugin.StepFinished", args, &jsonResp) if err != nil { log.Error().Err(err).Msg("StepFinished via addon failed") } } ================================================ FILE: server/services/log/addon/plugin.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "net/rpc" "github.com/hashicorp/go-plugin" "go.woodpecker-ci.org/woodpecker/v3/server/services/log" ) const pluginKey = "log" var HandshakeConfig = plugin.HandshakeConfig{ ProtocolVersion: 1, MagicCookieKey: "WOODPECKER_LOG_ADDON_PLUGIN", MagicCookieValue: "woodpecker-plugin-magic-cookie-value", } type Plugin struct { Impl log.Service } func (p *Plugin) Server(*plugin.MuxBroker) (any, error) { return &RPCServer{Impl: p.Impl}, nil } func (*Plugin) Client(_ *plugin.MuxBroker, c *rpc.Client) (any, error) { return &RPC{client: c}, nil } ================================================ FILE: server/services/log/addon/server.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package addon import ( "encoding/json" "github.com/hashicorp/go-plugin" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/log" ) func Serve(impl log.Service) { plugin.Serve(&plugin.ServeConfig{ HandshakeConfig: HandshakeConfig, Plugins: map[string]plugin.Plugin{ pluginKey: &Plugin{Impl: impl}, }, }) } type RPCServer struct { Impl log.Service } type argumentsAppend struct { Step *model.Step `json:"step"` LogEntries []*model.LogEntry `json:"log_entries"` } func (s *RPCServer) LogFind(args []byte, resp *[]byte) error { var a model.Step err := json.Unmarshal(args, &a) if err != nil { return err } log, err := s.Impl.LogFind(&a) if err != nil { return err } *resp, err = json.Marshal(log) return err } func (s *RPCServer) LogAppend(args []byte, resp *[]byte) error { var a argumentsAppend err := json.Unmarshal(args, &a) if err != nil { return err } *resp = []byte{} return s.Impl.LogAppend(a.Step, a.LogEntries) } func (s *RPCServer) LogDelete(args []byte, resp *[]byte) error { var a model.Step err := json.Unmarshal(args, &a) if err != nil { return err } *resp = []byte{} return s.Impl.LogDelete(&a) } func (s *RPCServer) StepFinished(args []byte, resp *[]byte) error { var a model.Step err := json.Unmarshal(args, &a) if err != nil { return err } *resp = []byte{} s.Impl.StepFinished(&a) return nil } ================================================ FILE: server/services/log/file/file.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package file import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/pipeline" "go.woodpecker-ci.org/woodpecker/v3/server/model" service_log "go.woodpecker-ci.org/woodpecker/v3/server/services/log" ) const ( // Add base64 overhead and space for other JSON fields (just to be safe). maxLineLength int = (pipeline.MaxLogLineLength/3)*4 + (64 * 1024) //nolint:mnd ) type logStore struct { base string } func NewLogStore(base string) (service_log.Service, error) { if base == "" { return nil, fmt.Errorf("file storage base path is required") } if _, err := os.Stat(base); err != nil && os.IsNotExist(err) { err = os.MkdirAll(base, 0o700) if err != nil { return nil, err } } return logStore{base: base}, nil } func (l logStore) filePath(id int64) string { return filepath.Join(l.base, fmt.Sprintf("%d.json", id)) } func (l logStore) LogFind(step *model.Step) ([]*model.LogEntry, error) { filename := l.filePath(step.ID) file, err := os.Open(filename) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer file.Close() buf := make([]byte, 0, bufio.MaxScanTokenSize) s := bufio.NewScanner(file) s.Buffer(buf, maxLineLength) var entries []*model.LogEntry for s.Scan() { j := s.Text() if len(strings.TrimSpace(j)) == 0 { continue } entry := &model.LogEntry{} err = json.Unmarshal([]byte(j), entry) if err != nil { return nil, err } entries = append(entries, entry) } return entries, nil } func (l logStore) LogAppend(step *model.Step, logEntries []*model.LogEntry) error { path := l.filePath(step.ID) file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { log.Error().Err(err).Msgf("could not open log file %s", path) return err } var bytes []byte for _, logEntry := range logEntries { if jsonLine, err := json.Marshal(logEntry); err == nil { bytes = append(bytes, jsonLine...) bytes = append(bytes, byte('\n')) } else { log.Error().Err(err).Msg("could not convert log entry to JSON") } } if _, err = file.Write(bytes); err != nil { log.Error().Err(err).Msg("could not write out log entries") } return file.Close() } func (l logStore) LogDelete(step *model.Step) error { return os.Remove(l.filePath(step.ID)) } func (l logStore) StepFinished(_ *model.Step) {} ================================================ FILE: server/services/log/mocks/mock_Service.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockService(t interface { mock.TestingT Cleanup(func()) }) *MockService { mock := &MockService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockService is an autogenerated mock type for the Service type type MockService struct { mock.Mock } type MockService_Expecter struct { mock *mock.Mock } func (_m *MockService) EXPECT() *MockService_Expecter { return &MockService_Expecter{mock: &_m.Mock} } // LogAppend provides a mock function for the type MockService func (_mock *MockService) LogAppend(step *model.Step, logEntries []*model.LogEntry) error { ret := _mock.Called(step, logEntries) if len(ret) == 0 { panic("no return value specified for LogAppend") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Step, []*model.LogEntry) error); ok { r0 = returnFunc(step, logEntries) } else { r0 = ret.Error(0) } return r0 } // MockService_LogAppend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogAppend' type MockService_LogAppend_Call struct { *mock.Call } // LogAppend is a helper method to define mock.On call // - step *model.Step // - logEntries []*model.LogEntry func (_e *MockService_Expecter) LogAppend(step interface{}, logEntries interface{}) *MockService_LogAppend_Call { return &MockService_LogAppend_Call{Call: _e.mock.On("LogAppend", step, logEntries)} } func (_c *MockService_LogAppend_Call) Run(run func(step *model.Step, logEntries []*model.LogEntry)) *MockService_LogAppend_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } var arg1 []*model.LogEntry if args[1] != nil { arg1 = args[1].([]*model.LogEntry) } run( arg0, arg1, ) }) return _c } func (_c *MockService_LogAppend_Call) Return(err error) *MockService_LogAppend_Call { _c.Call.Return(err) return _c } func (_c *MockService_LogAppend_Call) RunAndReturn(run func(step *model.Step, logEntries []*model.LogEntry) error) *MockService_LogAppend_Call { _c.Call.Return(run) return _c } // LogDelete provides a mock function for the type MockService func (_mock *MockService) LogDelete(step *model.Step) error { ret := _mock.Called(step) if len(ret) == 0 { panic("no return value specified for LogDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Step) error); ok { r0 = returnFunc(step) } else { r0 = ret.Error(0) } return r0 } // MockService_LogDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogDelete' type MockService_LogDelete_Call struct { *mock.Call } // LogDelete is a helper method to define mock.On call // - step *model.Step func (_e *MockService_Expecter) LogDelete(step interface{}) *MockService_LogDelete_Call { return &MockService_LogDelete_Call{Call: _e.mock.On("LogDelete", step)} } func (_c *MockService_LogDelete_Call) Run(run func(step *model.Step)) *MockService_LogDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockService_LogDelete_Call) Return(err error) *MockService_LogDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_LogDelete_Call) RunAndReturn(run func(step *model.Step) error) *MockService_LogDelete_Call { _c.Call.Return(run) return _c } // LogFind provides a mock function for the type MockService func (_mock *MockService) LogFind(step *model.Step) ([]*model.LogEntry, error) { ret := _mock.Called(step) if len(ret) == 0 { panic("no return value specified for LogFind") } var r0 []*model.LogEntry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Step) ([]*model.LogEntry, error)); ok { return returnFunc(step) } if returnFunc, ok := ret.Get(0).(func(*model.Step) []*model.LogEntry); ok { r0 = returnFunc(step) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.LogEntry) } } if returnFunc, ok := ret.Get(1).(func(*model.Step) error); ok { r1 = returnFunc(step) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_LogFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogFind' type MockService_LogFind_Call struct { *mock.Call } // LogFind is a helper method to define mock.On call // - step *model.Step func (_e *MockService_Expecter) LogFind(step interface{}) *MockService_LogFind_Call { return &MockService_LogFind_Call{Call: _e.mock.On("LogFind", step)} } func (_c *MockService_LogFind_Call) Run(run func(step *model.Step)) *MockService_LogFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockService_LogFind_Call) Return(logEntrys []*model.LogEntry, err error) *MockService_LogFind_Call { _c.Call.Return(logEntrys, err) return _c } func (_c *MockService_LogFind_Call) RunAndReturn(run func(step *model.Step) ([]*model.LogEntry, error)) *MockService_LogFind_Call { _c.Call.Return(run) return _c } // StepFinished provides a mock function for the type MockService func (_mock *MockService) StepFinished(step *model.Step) { _mock.Called(step) return } // MockService_StepFinished_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepFinished' type MockService_StepFinished_Call struct { *mock.Call } // StepFinished is a helper method to define mock.On call // - step *model.Step func (_e *MockService_Expecter) StepFinished(step interface{}) *MockService_StepFinished_Call { return &MockService_StepFinished_Call{Call: _e.mock.On("StepFinished", step)} } func (_c *MockService_StepFinished_Call) Run(run func(step *model.Step)) *MockService_StepFinished_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockService_StepFinished_Call) Return() *MockService_StepFinished_Call { _c.Call.Return() return _c } func (_c *MockService_StepFinished_Call) RunAndReturn(run func(step *model.Step)) *MockService_StepFinished_Call { _c.Run(run) return _c } ================================================ FILE: server/services/log/service.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package log import "go.woodpecker-ci.org/woodpecker/v3/server/model" type Service interface { LogFind(step *model.Step) ([]*model.LogEntry, error) LogAppend(step *model.Step, logEntries []*model.LogEntry) error LogDelete(step *model.Step) error StepFinished(step *model.Step) } ================================================ FILE: server/services/manager.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package services import ( "crypto" "strings" "time" "github.com/jellydator/ttlcache/v3" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" "go.woodpecker-ci.org/woodpecker/v3/server/services/environment" "go.woodpecker-ci.org/woodpecker/v3/server/services/registry" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) const forgeCacheTTL = 10 * time.Minute type SetupForge func(forge *model.Forge) (forge.Forge, error) type Manager interface { SignaturePublicKey() crypto.PublicKey SecretServiceFromRepo(repo *model.Repo) secret.Service SecretService() secret.Service RegistryServiceFromRepo(repo *model.Repo) registry.Service RegistryService() registry.Service ConfigServiceFromRepo(repo *model.Repo) config.Service EnvironmentService() environment.Service ForgeFromRepo(repo *model.Repo) (forge.Forge, error) ForgeFromUser(user *model.User) (forge.Forge, error) ForgeByID(forgeID int64) (forge.Forge, error) } type manager struct { signaturePrivateKey crypto.PrivateKey signaturePublicKey crypto.PublicKey store store.Store secret secret.Service registry registry.Service config config.Service environment environment.Service forgeCache *ttlcache.Cache[int64, forge.Forge] setupForge SetupForge client *utils.Client } func NewManager(c *cli.Command, store store.Store, setupForge SetupForge) (Manager, error) { signaturePrivateKey, signaturePublicKey, err := setupSignatureKeys(store) if err != nil { return nil, err } err = setupForgeService(c, store) if err != nil { return nil, err } client, err := utils.NewHTTPClient(signaturePrivateKey, c.String("extensions-allowed-hosts")) if err != nil { return nil, err } configService, err := setupConfigService(c, client) if err != nil { return nil, err } return &manager{ signaturePrivateKey: signaturePrivateKey, signaturePublicKey: signaturePublicKey, store: store, secret: setupSecretService(store, c.String("secret-extension-endpoint"), client, c.Bool("secret-extension-netrc")), registry: setupRegistryService(store, c.String("docker-config"), c.String("registry-extension-endpoint"), c.Bool("registry-extension-netrc"), client), config: configService, environment: environment.Parse(c.StringSlice("environment")), forgeCache: ttlcache.New(ttlcache.WithDisableTouchOnHit[int64, forge.Forge]()), setupForge: setupForge, client: client, }, nil } func (m *manager) SignaturePublicKey() crypto.PublicKey { return m.signaturePublicKey } func (m *manager) SecretServiceFromRepo(repo *model.Repo) secret.Service { if repo.SecretExtensionEndpoint != "" { return secret.NewCombined(m.secret, secret.NewHTTP(strings.TrimRight(repo.SecretExtensionEndpoint, "/"), m.client, repo.SecretExtensionNetrc)) } return m.SecretService() } func (m *manager) SecretService() secret.Service { return m.secret } func (m *manager) RegistryServiceFromRepo(repo *model.Repo) registry.Service { if repo.RegistryExtensionEndpoint != "" { return registry.NewWithExtension(m.registry, registry.NewHTTP(strings.TrimRight(repo.RegistryExtensionEndpoint, "/"), m.client, repo.RegistryExtensionNetrc)) } return m.RegistryService() } func (m *manager) RegistryService() registry.Service { return m.registry } func (m *manager) ConfigServiceFromRepo(repo *model.Repo) config.Service { if repo.ConfigExtensionEndpoint != "" { if repo.ConfigExtensionExclusive { return config.NewHTTP(strings.TrimRight(repo.ConfigExtensionEndpoint, "/"), m.client, repo.ConfigExtensionNetrc) } return config.NewCombined(m.config, config.NewHTTP(strings.TrimRight(repo.ConfigExtensionEndpoint, "/"), m.client, repo.ConfigExtensionNetrc)) } return m.config } func (m *manager) EnvironmentService() environment.Service { return m.environment } func (m *manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) { return m.ForgeByID(repo.ForgeID) } func (m *manager) ForgeFromUser(user *model.User) (forge.Forge, error) { return m.ForgeByID(user.ForgeID) } func (m *manager) ForgeByID(id int64) (forge.Forge, error) { item := m.forgeCache.Get(id) if item != nil && !item.IsExpired() { return item.Value(), nil } forgeModel, err := m.store.ForgeGet(id) if err != nil { return nil, err } forge, err := m.setupForge(forgeModel) if err != nil { return nil, err } m.forgeCache.Set(id, forge, forgeCacheTTL) return forge, nil } ================================================ FILE: server/services/mocks/mock_Manager.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "crypto" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/forge" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" "go.woodpecker-ci.org/woodpecker/v3/server/services/environment" "go.woodpecker-ci.org/woodpecker/v3/server/services/registry" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" ) // NewMockManager creates a new instance of MockManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockManager(t interface { mock.TestingT Cleanup(func()) }) *MockManager { mock := &MockManager{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockManager is an autogenerated mock type for the Manager type type MockManager struct { mock.Mock } type MockManager_Expecter struct { mock *mock.Mock } func (_m *MockManager) EXPECT() *MockManager_Expecter { return &MockManager_Expecter{mock: &_m.Mock} } // ConfigServiceFromRepo provides a mock function for the type MockManager func (_mock *MockManager) ConfigServiceFromRepo(repo *model.Repo) config.Service { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for ConfigServiceFromRepo") } var r0 config.Service if returnFunc, ok := ret.Get(0).(func(*model.Repo) config.Service); ok { r0 = returnFunc(repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(config.Service) } } return r0 } // MockManager_ConfigServiceFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigServiceFromRepo' type MockManager_ConfigServiceFromRepo_Call struct { *mock.Call } // ConfigServiceFromRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockManager_Expecter) ConfigServiceFromRepo(repo interface{}) *MockManager_ConfigServiceFromRepo_Call { return &MockManager_ConfigServiceFromRepo_Call{Call: _e.mock.On("ConfigServiceFromRepo", repo)} } func (_c *MockManager_ConfigServiceFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_ConfigServiceFromRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockManager_ConfigServiceFromRepo_Call) Return(service config.Service) *MockManager_ConfigServiceFromRepo_Call { _c.Call.Return(service) return _c } func (_c *MockManager_ConfigServiceFromRepo_Call) RunAndReturn(run func(repo *model.Repo) config.Service) *MockManager_ConfigServiceFromRepo_Call { _c.Call.Return(run) return _c } // EnvironmentService provides a mock function for the type MockManager func (_mock *MockManager) EnvironmentService() environment.Service { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for EnvironmentService") } var r0 environment.Service if returnFunc, ok := ret.Get(0).(func() environment.Service); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(environment.Service) } } return r0 } // MockManager_EnvironmentService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnvironmentService' type MockManager_EnvironmentService_Call struct { *mock.Call } // EnvironmentService is a helper method to define mock.On call func (_e *MockManager_Expecter) EnvironmentService() *MockManager_EnvironmentService_Call { return &MockManager_EnvironmentService_Call{Call: _e.mock.On("EnvironmentService")} } func (_c *MockManager_EnvironmentService_Call) Run(run func()) *MockManager_EnvironmentService_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockManager_EnvironmentService_Call) Return(service environment.Service) *MockManager_EnvironmentService_Call { _c.Call.Return(service) return _c } func (_c *MockManager_EnvironmentService_Call) RunAndReturn(run func() environment.Service) *MockManager_EnvironmentService_Call { _c.Call.Return(run) return _c } // ForgeByID provides a mock function for the type MockManager func (_mock *MockManager) ForgeByID(forgeID int64) (forge.Forge, error) { ret := _mock.Called(forgeID) if len(ret) == 0 { panic("no return value specified for ForgeByID") } var r0 forge.Forge var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (forge.Forge, error)); ok { return returnFunc(forgeID) } if returnFunc, ok := ret.Get(0).(func(int64) forge.Forge); ok { r0 = returnFunc(forgeID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(forge.Forge) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(forgeID) } else { r1 = ret.Error(1) } return r0, r1 } // MockManager_ForgeByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeByID' type MockManager_ForgeByID_Call struct { *mock.Call } // ForgeByID is a helper method to define mock.On call // - forgeID int64 func (_e *MockManager_Expecter) ForgeByID(forgeID interface{}) *MockManager_ForgeByID_Call { return &MockManager_ForgeByID_Call{Call: _e.mock.On("ForgeByID", forgeID)} } func (_c *MockManager_ForgeByID_Call) Run(run func(forgeID int64)) *MockManager_ForgeByID_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockManager_ForgeByID_Call) Return(forge1 forge.Forge, err error) *MockManager_ForgeByID_Call { _c.Call.Return(forge1, err) return _c } func (_c *MockManager_ForgeByID_Call) RunAndReturn(run func(forgeID int64) (forge.Forge, error)) *MockManager_ForgeByID_Call { _c.Call.Return(run) return _c } // ForgeFromRepo provides a mock function for the type MockManager func (_mock *MockManager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for ForgeFromRepo") } var r0 forge.Forge var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo) (forge.Forge, error)); ok { return returnFunc(repo) } if returnFunc, ok := ret.Get(0).(func(*model.Repo) forge.Forge); ok { r0 = returnFunc(repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(forge.Forge) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo) error); ok { r1 = returnFunc(repo) } else { r1 = ret.Error(1) } return r0, r1 } // MockManager_ForgeFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeFromRepo' type MockManager_ForgeFromRepo_Call struct { *mock.Call } // ForgeFromRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockManager_Expecter) ForgeFromRepo(repo interface{}) *MockManager_ForgeFromRepo_Call { return &MockManager_ForgeFromRepo_Call{Call: _e.mock.On("ForgeFromRepo", repo)} } func (_c *MockManager_ForgeFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_ForgeFromRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockManager_ForgeFromRepo_Call) Return(forge1 forge.Forge, err error) *MockManager_ForgeFromRepo_Call { _c.Call.Return(forge1, err) return _c } func (_c *MockManager_ForgeFromRepo_Call) RunAndReturn(run func(repo *model.Repo) (forge.Forge, error)) *MockManager_ForgeFromRepo_Call { _c.Call.Return(run) return _c } // ForgeFromUser provides a mock function for the type MockManager func (_mock *MockManager) ForgeFromUser(user *model.User) (forge.Forge, error) { ret := _mock.Called(user) if len(ret) == 0 { panic("no return value specified for ForgeFromUser") } var r0 forge.Forge var r1 error if returnFunc, ok := ret.Get(0).(func(*model.User) (forge.Forge, error)); ok { return returnFunc(user) } if returnFunc, ok := ret.Get(0).(func(*model.User) forge.Forge); ok { r0 = returnFunc(user) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(forge.Forge) } } if returnFunc, ok := ret.Get(1).(func(*model.User) error); ok { r1 = returnFunc(user) } else { r1 = ret.Error(1) } return r0, r1 } // MockManager_ForgeFromUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeFromUser' type MockManager_ForgeFromUser_Call struct { *mock.Call } // ForgeFromUser is a helper method to define mock.On call // - user *model.User func (_e *MockManager_Expecter) ForgeFromUser(user interface{}) *MockManager_ForgeFromUser_Call { return &MockManager_ForgeFromUser_Call{Call: _e.mock.On("ForgeFromUser", user)} } func (_c *MockManager_ForgeFromUser_Call) Run(run func(user *model.User)) *MockManager_ForgeFromUser_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } run( arg0, ) }) return _c } func (_c *MockManager_ForgeFromUser_Call) Return(forge1 forge.Forge, err error) *MockManager_ForgeFromUser_Call { _c.Call.Return(forge1, err) return _c } func (_c *MockManager_ForgeFromUser_Call) RunAndReturn(run func(user *model.User) (forge.Forge, error)) *MockManager_ForgeFromUser_Call { _c.Call.Return(run) return _c } // RegistryService provides a mock function for the type MockManager func (_mock *MockManager) RegistryService() registry.Service { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for RegistryService") } var r0 registry.Service if returnFunc, ok := ret.Get(0).(func() registry.Service); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(registry.Service) } } return r0 } // MockManager_RegistryService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryService' type MockManager_RegistryService_Call struct { *mock.Call } // RegistryService is a helper method to define mock.On call func (_e *MockManager_Expecter) RegistryService() *MockManager_RegistryService_Call { return &MockManager_RegistryService_Call{Call: _e.mock.On("RegistryService")} } func (_c *MockManager_RegistryService_Call) Run(run func()) *MockManager_RegistryService_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockManager_RegistryService_Call) Return(service registry.Service) *MockManager_RegistryService_Call { _c.Call.Return(service) return _c } func (_c *MockManager_RegistryService_Call) RunAndReturn(run func() registry.Service) *MockManager_RegistryService_Call { _c.Call.Return(run) return _c } // RegistryServiceFromRepo provides a mock function for the type MockManager func (_mock *MockManager) RegistryServiceFromRepo(repo *model.Repo) registry.Service { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for RegistryServiceFromRepo") } var r0 registry.Service if returnFunc, ok := ret.Get(0).(func(*model.Repo) registry.Service); ok { r0 = returnFunc(repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(registry.Service) } } return r0 } // MockManager_RegistryServiceFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryServiceFromRepo' type MockManager_RegistryServiceFromRepo_Call struct { *mock.Call } // RegistryServiceFromRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockManager_Expecter) RegistryServiceFromRepo(repo interface{}) *MockManager_RegistryServiceFromRepo_Call { return &MockManager_RegistryServiceFromRepo_Call{Call: _e.mock.On("RegistryServiceFromRepo", repo)} } func (_c *MockManager_RegistryServiceFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_RegistryServiceFromRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockManager_RegistryServiceFromRepo_Call) Return(service registry.Service) *MockManager_RegistryServiceFromRepo_Call { _c.Call.Return(service) return _c } func (_c *MockManager_RegistryServiceFromRepo_Call) RunAndReturn(run func(repo *model.Repo) registry.Service) *MockManager_RegistryServiceFromRepo_Call { _c.Call.Return(run) return _c } // SecretService provides a mock function for the type MockManager func (_mock *MockManager) SecretService() secret.Service { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for SecretService") } var r0 secret.Service if returnFunc, ok := ret.Get(0).(func() secret.Service); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(secret.Service) } } return r0 } // MockManager_SecretService_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretService' type MockManager_SecretService_Call struct { *mock.Call } // SecretService is a helper method to define mock.On call func (_e *MockManager_Expecter) SecretService() *MockManager_SecretService_Call { return &MockManager_SecretService_Call{Call: _e.mock.On("SecretService")} } func (_c *MockManager_SecretService_Call) Run(run func()) *MockManager_SecretService_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockManager_SecretService_Call) Return(service secret.Service) *MockManager_SecretService_Call { _c.Call.Return(service) return _c } func (_c *MockManager_SecretService_Call) RunAndReturn(run func() secret.Service) *MockManager_SecretService_Call { _c.Call.Return(run) return _c } // SecretServiceFromRepo provides a mock function for the type MockManager func (_mock *MockManager) SecretServiceFromRepo(repo *model.Repo) secret.Service { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for SecretServiceFromRepo") } var r0 secret.Service if returnFunc, ok := ret.Get(0).(func(*model.Repo) secret.Service); ok { r0 = returnFunc(repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(secret.Service) } } return r0 } // MockManager_SecretServiceFromRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretServiceFromRepo' type MockManager_SecretServiceFromRepo_Call struct { *mock.Call } // SecretServiceFromRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockManager_Expecter) SecretServiceFromRepo(repo interface{}) *MockManager_SecretServiceFromRepo_Call { return &MockManager_SecretServiceFromRepo_Call{Call: _e.mock.On("SecretServiceFromRepo", repo)} } func (_c *MockManager_SecretServiceFromRepo_Call) Run(run func(repo *model.Repo)) *MockManager_SecretServiceFromRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockManager_SecretServiceFromRepo_Call) Return(service secret.Service) *MockManager_SecretServiceFromRepo_Call { _c.Call.Return(service) return _c } func (_c *MockManager_SecretServiceFromRepo_Call) RunAndReturn(run func(repo *model.Repo) secret.Service) *MockManager_SecretServiceFromRepo_Call { _c.Call.Return(run) return _c } // SignaturePublicKey provides a mock function for the type MockManager func (_mock *MockManager) SignaturePublicKey() crypto.PublicKey { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for SignaturePublicKey") } var r0 crypto.PublicKey if returnFunc, ok := ret.Get(0).(func() crypto.PublicKey); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).(crypto.PublicKey) } } return r0 } // MockManager_SignaturePublicKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignaturePublicKey' type MockManager_SignaturePublicKey_Call struct { *mock.Call } // SignaturePublicKey is a helper method to define mock.On call func (_e *MockManager_Expecter) SignaturePublicKey() *MockManager_SignaturePublicKey_Call { return &MockManager_SignaturePublicKey_Call{Call: _e.mock.On("SignaturePublicKey")} } func (_c *MockManager_SignaturePublicKey_Call) Run(run func()) *MockManager_SignaturePublicKey_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockManager_SignaturePublicKey_Call) Return(publicKey crypto.PublicKey) *MockManager_SignaturePublicKey_Call { _c.Call.Return(publicKey) return _c } func (_c *MockManager_SignaturePublicKey_Call) RunAndReturn(run func() crypto.PublicKey) *MockManager_SignaturePublicKey_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/services/permissions/admins.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package permissions import ( "strings" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) func NewAdmins(admins []string) *Admins { adminsLowercase := make([]string, len(admins)) for i, a := range admins { adminsLowercase[i] = strings.ToLower(a) } return &Admins{admins: utils.SliceToBoolMap(adminsLowercase)} } type Admins struct { admins map[string]bool } func (a *Admins) IsAdmin(user *model.User) bool { return a.admins[strings.ToLower(user.Login)] } ================================================ FILE: server/services/permissions/admins_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package permissions import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestAdmins(t *testing.T) { a := NewAdmins([]string{"woodpecker-ci"}) assert.True(t, a.IsAdmin(&model.User{Login: "woodpecker-ci"})) assert.False(t, a.IsAdmin(&model.User{Login: "not-woodpecker-ci"})) empty := NewAdmins([]string{}) assert.False(t, empty.IsAdmin(&model.User{Login: "woodpecker-ci"})) assert.False(t, empty.IsAdmin(&model.User{Login: "not-woodpecker-ci"})) } ================================================ FILE: server/services/permissions/orgs.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package permissions import ( "strings" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) func NewOrgs(orgs []string) *Orgs { orgsLowercase := make([]string, len(orgs)) for i, a := range orgs { orgsLowercase[i] = strings.ToLower(a) } return &Orgs{ IsConfigured: len(orgs) > 0, orgs: utils.SliceToBoolMap(orgsLowercase), } } type Orgs struct { IsConfigured bool orgs map[string]bool } func (o *Orgs) IsMember(teams []*model.Team) bool { for _, team := range teams { if o.orgs[strings.ToLower(team.Login)] { return true } } return false } ================================================ FILE: server/services/permissions/orgs_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package permissions import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestOrgs(t *testing.T) { o := NewOrgs([]string{"woodpecker-ci"}) assert.True(t, o.IsConfigured) assert.True(t, o.IsMember([]*model.Team{{Login: "woodpecker-ci"}})) assert.False(t, o.IsMember([]*model.Team{{Login: "not-woodpecker-ci"}})) empty := NewOrgs([]string{}) assert.False(t, empty.IsConfigured) assert.False(t, empty.IsMember([]*model.Team{{Login: "woodpecker-ci"}})) assert.False(t, empty.IsMember([]*model.Team{{Login: "not-woodpecker-ci"}})) } ================================================ FILE: server/services/permissions/repo_owners.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package permissions import ( "strings" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/shared/utils" ) func NewOwnersAllowlist(owners []string) *OwnersAllowlist { ownersLowercase := make([]string, len(owners)) for i, a := range owners { ownersLowercase[i] = strings.ToLower(a) } return &OwnersAllowlist{owners: utils.SliceToBoolMap(ownersLowercase)} } type OwnersAllowlist struct { owners map[string]bool } func (o *OwnersAllowlist) IsAllowed(repo *model.Repo) bool { return len(o.owners) < 1 || o.owners[strings.ToLower(repo.Owner)] } ================================================ FILE: server/services/permissions/repo_owners_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package permissions import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestOwnersAllowlist(t *testing.T) { ol := NewOwnersAllowlist([]string{"woodpecker-ci"}) assert.True(t, ol.IsAllowed(&model.Repo{Owner: "woodpecker-ci"})) assert.False(t, ol.IsAllowed(&model.Repo{Owner: "not-woodpecker-ci"})) empty := NewOwnersAllowlist([]string{}) assert.True(t, empty.IsAllowed(&model.Repo{Owner: "woodpecker-ci"})) assert.True(t, empty.IsAllowed(&model.Repo{Owner: "not-woodpecker-ci"})) } ================================================ FILE: server/services/registry/combined.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "errors" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) type combined struct { registries []ReadOnlyService dbRegistry Service } func NewCombined(dbRegistry Service, registries ...ReadOnlyService) Service { registries = append(registries, dbRegistry) return &combined{ registries: registries, dbRegistry: dbRegistry, } } func (c *combined) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { return c.dbRegistry.RegistryFind(repo, addr) } func (c *combined) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { return c.dbRegistry.RegistryList(repo, p) } func (c *combined) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) { dbRegistries, err := c.dbRegistry.RegistryListPipeline(ctx, repo, pipeline, netrc) if err != nil { return nil, err } registries := make([]*model.Registry, 0, len(dbRegistries)) exists := make(map[string]struct{}, len(dbRegistries)) // Assign database stored registries to the map to avoid duplicates // from the combined registries so to prioritize ones in database. for _, reg := range dbRegistries { exists[reg.Address] = struct{}{} } for _, registry := range c.registries { list, err := registry.GlobalRegistryList(&model.ListOptions{All: true}) if err != nil { return nil, err } for _, reg := range list { if _, ok := exists[reg.Address]; ok { continue } exists[reg.Address] = struct{}{} registries = append(registries, reg) } } return append(registries, dbRegistries...), nil } func (c *combined) RegistryCreate(repo *model.Repo, registry *model.Registry) error { return c.dbRegistry.RegistryCreate(repo, registry) } func (c *combined) RegistryUpdate(repo *model.Repo, registry *model.Registry) error { return c.dbRegistry.RegistryUpdate(repo, registry) } func (c *combined) RegistryDelete(repo *model.Repo, addr string) error { return c.dbRegistry.RegistryDelete(repo, addr) } func (c *combined) OrgRegistryFind(owner int64, addr string) (*model.Registry, error) { return c.dbRegistry.OrgRegistryFind(owner, addr) } func (c *combined) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) { return c.dbRegistry.OrgRegistryList(owner, p) } func (c *combined) OrgRegistryCreate(owner int64, registry *model.Registry) error { return c.dbRegistry.OrgRegistryCreate(owner, registry) } func (c *combined) OrgRegistryUpdate(owner int64, registry *model.Registry) error { return c.dbRegistry.OrgRegistryUpdate(owner, registry) } func (c *combined) OrgRegistryDelete(owner int64, addr string) error { return c.dbRegistry.OrgRegistryDelete(owner, addr) } func (c *combined) GlobalRegistryFind(addr string) (*model.Registry, error) { registry, err := c.dbRegistry.GlobalRegistryFind(addr) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { return nil, err } if registry != nil { return registry, nil } for _, reg := range c.registries { if registry, err := reg.GlobalRegistryFind(addr); err == nil { return registry, nil } } return nil, types.ErrRecordNotExist } func (c *combined) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { dbRegistries, err := c.dbRegistry.GlobalRegistryList(&model.ListOptions{All: true}) if err != nil { return nil, err } registries := make([]*model.Registry, 0, len(dbRegistries)) exists := make(map[string]struct{}, len(dbRegistries)) // Assign database stored registries to the map to avoid duplicates // from the combined registries so to prioritize ones in database. for _, reg := range dbRegistries { exists[reg.Address] = struct{}{} } for _, registry := range c.registries { list, err := registry.GlobalRegistryList(&model.ListOptions{All: true}) if err != nil { return nil, err } for _, reg := range list { if _, ok := exists[reg.Address]; ok { continue } exists[reg.Address] = struct{}{} registries = append(registries, reg) } } return model.ApplyPagination(p, append(registries, dbRegistries...)), nil } func (c *combined) GlobalRegistryCreate(registry *model.Registry) error { return c.dbRegistry.GlobalRegistryCreate(registry) } func (c *combined) GlobalRegistryUpdate(registry *model.Registry) error { return c.dbRegistry.GlobalRegistryUpdate(registry) } func (c *combined) GlobalRegistryDelete(addr string) error { return c.dbRegistry.GlobalRegistryDelete(addr) } ================================================ FILE: server/services/registry/combined_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/server/model" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestCombinedRegistryListPipeline(t *testing.T) { t.Parallel() testTable := []struct { name string repoName string dbRegs []*model.Registry expected []*model.Registry expectedError bool }{ { name: "DB registries override file registry", repoName: "override-test", dbRegs: []*model.Registry{ {ID: 1, RepoID: 1, Address: "docker.io", Username: "shared", Password: "db-value"}, {ID: 2, RepoID: 1, Address: "quay.io", Username: "db-only", Password: "only-in-db"}, }, expected: []*model.Registry{ {Address: "example.com", Username: "user", Password: "password-encoded", ReadOnly: true}, {ID: 1, RepoID: 1, Address: "docker.io", Username: "shared", Password: "db-value"}, {ID: 2, RepoID: 1, Address: "quay.io", Username: "db-only", Password: "only-in-db"}, }, expectedError: false, }, { name: "No overriding, but merged", repoName: "no-content", dbRegs: []*model.Registry{ {ID: 1, RepoID: 1, Address: "quay.io", Username: "db-secret", Password: "db-value"}, }, expected: []*model.Registry{ {Address: "docker.io", Username: "user", Password: "your-pw", ReadOnly: true}, {Address: "example.com", Username: "user", Password: "password-encoded", ReadOnly: true}, {ID: 1, RepoID: 1, Address: "quay.io", Username: "db-secret", Password: "db-value"}, }, expectedError: false, }, } tmpFile, err := os.CreateTemp(t.TempDir(), "registry-test-combined-*.json") require.NoError(t, err) _, err = tmpFile.WriteString(`{"auths": {"docker.io": {"username": "user", "password": "your-pw"}, "example.com": {"auth": "dXNlcjpwYXNzd29yZC1lbmNvZGVk"}}}`) require.NoError(t, err) fsService := NewFilesystem(tmpFile.Name()) for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("RegistryList", mock.Anything, true, mock.Anything).Return(tt.dbRegs, nil) mockStore.On("GlobalRegistryList", mock.Anything).Return(nil, nil) combined := NewCombined(NewDB(mockStore), fsService) registries, err := combined.RegistryListPipeline( t.Context(), &model.Repo{ID: 1, Name: tt.repoName}, &model.Pipeline{}, nil, ) if tt.expectedError { require.Error(t, err, "expected an error") } else { require.NoError(t, err, "error fetching registries") } assert.ElementsMatch(t, tt.expected, registries, "expected some other registries") }) } } ================================================ FILE: server/services/registry/db.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type db struct { store store.Store } // New returns a new local registry service. func NewDB(store store.Store) Service { return &db{store} } func (d *db) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { return d.store.RegistryFind(repo, addr) } func (d *db) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { return d.store.RegistryList(repo, false, p) } func (d *db) RegistryListPipeline(_ context.Context, repo *model.Repo, _ *model.Pipeline, _ *model.Netrc) ([]*model.Registry, error) { r, err := d.store.RegistryList(repo, true, &model.ListOptions{All: true}) if err != nil { return nil, err } // Return only registries with unique address // Priority order in case of duplicate addresses are repository, user/organization, global registries := make([]*model.Registry, 0, len(r)) uniq := make(map[string]struct{}) for _, condition := range []struct { IsRepository bool IsOrganization bool IsGlobal bool }{ {IsRepository: true}, {IsOrganization: true}, {IsGlobal: true}, } { for _, registry := range r { if registry.IsRepository() != condition.IsRepository || registry.IsOrganization() != condition.IsOrganization || registry.IsGlobal() != condition.IsGlobal { continue } if _, ok := uniq[registry.Address]; ok { continue } uniq[registry.Address] = struct{}{} registries = append(registries, registry) } } return registries, nil } func (d *db) RegistryCreate(_ *model.Repo, in *model.Registry) error { return d.store.RegistryCreate(in) } func (d *db) RegistryUpdate(_ *model.Repo, in *model.Registry) error { return d.store.RegistryUpdate(in) } func (d *db) RegistryDelete(repo *model.Repo, addr string) error { registry, err := d.store.RegistryFind(repo, addr) if err != nil { return err } return d.store.RegistryDelete(registry) } func (d *db) OrgRegistryFind(owner int64, name string) (*model.Registry, error) { return d.store.OrgRegistryFind(owner, name) } func (d *db) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) { return d.store.OrgRegistryList(owner, p) } func (d *db) OrgRegistryCreate(_ int64, in *model.Registry) error { return d.store.RegistryCreate(in) } func (d *db) OrgRegistryUpdate(_ int64, in *model.Registry) error { return d.store.RegistryUpdate(in) } func (d *db) OrgRegistryDelete(owner int64, addr string) error { registry, err := d.store.OrgRegistryFind(owner, addr) if err != nil { return err } return d.store.RegistryDelete(registry) } func (d *db) GlobalRegistryFind(addr string) (*model.Registry, error) { return d.store.GlobalRegistryFind(addr) } func (d *db) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { return d.store.GlobalRegistryList(p) } func (d *db) GlobalRegistryCreate(in *model.Registry) error { return d.store.RegistryCreate(in) } func (d *db) GlobalRegistryUpdate(in *model.Registry) error { return d.store.RegistryUpdate(in) } func (d *db) GlobalRegistryDelete(addr string) error { registry, err := d.store.GlobalRegistryFind(addr) if err != nil { return err } return d.store.RegistryDelete(registry) } ================================================ FILE: server/services/registry/filesystem.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "encoding/base64" "encoding/json" "fmt" "os" "strings" "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/types" "go.woodpecker-ci.org/woodpecker/v3/server/model" store_types "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) type filesystem struct { path string } func NewFilesystem(path string) ReadOnlyService { return &filesystem{path} } func parseDockerConfig(path string) ([]*model.Registry, error) { if path == "" { return nil, nil } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() configFile := configfile.ConfigFile{ AuthConfigs: make(map[string]types.AuthConfig), } if err := json.NewDecoder(f).Decode(&configFile); err != nil { return nil, err } for registryHostname := range configFile.CredentialHelpers { newAuth, err := configFile.GetAuthConfig(registryHostname) if err == nil { configFile.AuthConfigs[registryHostname] = newAuth } } for addr, ac := range configFile.AuthConfigs { if ac.Auth != "" { ac.Username, ac.Password, err = decodeAuth(ac.Auth) if err != nil { return nil, err } ac.Auth = "" ac.ServerAddress = addr configFile.AuthConfigs[addr] = ac } } var registries []*model.Registry for key, auth := range configFile.AuthConfigs { registries = append(registries, &model.Registry{ Address: key, Username: auth.Username, Password: auth.Password, ReadOnly: true, }) } return registries, nil } func (f *filesystem) GlobalRegistryFind(addr string) (*model.Registry, error) { registries, err := f.GlobalRegistryList(&model.ListOptions{All: true}) if err != nil { return nil, err } for _, reg := range registries { if reg.Address == addr { return reg, nil } } return nil, store_types.ErrRecordNotExist } func (f *filesystem) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { regs, err := parseDockerConfig(f.path) if err != nil { return nil, err } return model.ApplyPagination(p, regs), nil } // decodeAuth decodes a base64 encoded string and returns username and password. func decodeAuth(authStr string) (string, string, error) { if authStr == "" { return "", "", nil } decLen := base64.StdEncoding.DecodedLen(len(authStr)) decoded := make([]byte, decLen) authByte := []byte(authStr) n, err := base64.StdEncoding.Decode(decoded, authByte) if err != nil { return "", "", err } if n > decLen { return "", "", fmt.Errorf("something went wrong decoding auth config") } before, after, _ := strings.Cut(string(decoded), ":") if before == "" || after == "" { return "", "", fmt.Errorf("invalid auth configuration file") } password := strings.Trim(after, "\x00") return before, password, nil } ================================================ FILE: server/services/registry/http.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "fmt" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" ) type httpExtension struct { endpoint string client *utils.Client includeNetrc bool } type requestStructure struct { Repo *model.Repo `json:"repo"` Pipeline *model.Pipeline `json:"pipeline"` Netrc *model.Netrc `json:"netrc,omitempty"` } type responseStructure struct { Registries []*registryData `json:"registries"` } type registryData struct { Address string `json:"address"` Username string `json:"username"` Password string `json:"password"` } // NewHTTP returns a new HTTP registry extension client. func NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) *httpExtension { return &httpExtension{endpoint, client, includeNetrc} } // RegistryListPipeline fetches registry credentials from an external HTTP extension. func (h *httpExtension) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) { response := new(responseStructure) body := requestStructure{ Repo: repo, Pipeline: pipeline, } if h.includeNetrc { body.Netrc = netrc } status, err := h.client.Send(ctx, http.MethodPost, h.endpoint, body, response) if err != nil && status != http.StatusNoContent { return nil, fmt.Errorf("failed to fetch registries via http (%d) %w", status, err) } if status != http.StatusOK { // 204 No Content means no additional registries return nil, nil } registries := make([]*model.Registry, len(response.Registries)) for i, reg := range response.Registries { registries[i] = &model.Registry{ Address: reg.Address, Username: reg.Username, Password: reg.Password, } } return registries, nil } ================================================ FILE: server/services/registry/mocks/mock_ReadOnlyService.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockReadOnlyService creates a new instance of MockReadOnlyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockReadOnlyService(t interface { mock.TestingT Cleanup(func()) }) *MockReadOnlyService { mock := &MockReadOnlyService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockReadOnlyService is an autogenerated mock type for the ReadOnlyService type type MockReadOnlyService struct { mock.Mock } type MockReadOnlyService_Expecter struct { mock *mock.Mock } func (_m *MockReadOnlyService) EXPECT() *MockReadOnlyService_Expecter { return &MockReadOnlyService_Expecter{mock: &_m.Mock} } // GlobalRegistryFind provides a mock function for the type MockReadOnlyService func (_mock *MockReadOnlyService) GlobalRegistryFind(s string) (*model.Registry, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalRegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Registry); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockReadOnlyService_GlobalRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryFind' type MockReadOnlyService_GlobalRegistryFind_Call struct { *mock.Call } // GlobalRegistryFind is a helper method to define mock.On call // - s string func (_e *MockReadOnlyService_Expecter) GlobalRegistryFind(s interface{}) *MockReadOnlyService_GlobalRegistryFind_Call { return &MockReadOnlyService_GlobalRegistryFind_Call{Call: _e.mock.On("GlobalRegistryFind", s)} } func (_c *MockReadOnlyService_GlobalRegistryFind_Call) Run(run func(s string)) *MockReadOnlyService_GlobalRegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockReadOnlyService_GlobalRegistryFind_Call) Return(registry *model.Registry, err error) *MockReadOnlyService_GlobalRegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockReadOnlyService_GlobalRegistryFind_Call) RunAndReturn(run func(s string) (*model.Registry, error)) *MockReadOnlyService_GlobalRegistryFind_Call { _c.Call.Return(run) return _c } // GlobalRegistryList provides a mock function for the type MockReadOnlyService func (_mock *MockReadOnlyService) GlobalRegistryList(listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(listOptions) if len(ret) == 0 { panic("no return value specified for GlobalRegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok { r0 = returnFunc(listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockReadOnlyService_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList' type MockReadOnlyService_GlobalRegistryList_Call struct { *mock.Call } // GlobalRegistryList is a helper method to define mock.On call // - listOptions *model.ListOptions func (_e *MockReadOnlyService_Expecter) GlobalRegistryList(listOptions interface{}) *MockReadOnlyService_GlobalRegistryList_Call { return &MockReadOnlyService_GlobalRegistryList_Call{Call: _e.mock.On("GlobalRegistryList", listOptions)} } func (_c *MockReadOnlyService_GlobalRegistryList_Call) Run(run func(listOptions *model.ListOptions)) *MockReadOnlyService_GlobalRegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockReadOnlyService_GlobalRegistryList_Call) Return(registrys []*model.Registry, err error) *MockReadOnlyService_GlobalRegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockReadOnlyService_GlobalRegistryList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Registry, error)) *MockReadOnlyService_GlobalRegistryList_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/services/registry/mocks/mock_Service.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockService(t interface { mock.TestingT Cleanup(func()) }) *MockService { mock := &MockService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockService is an autogenerated mock type for the Service type type MockService struct { mock.Mock } type MockService_Expecter struct { mock *mock.Mock } func (_m *MockService) EXPECT() *MockService_Expecter { return &MockService_Expecter{mock: &_m.Mock} } // GlobalRegistryCreate provides a mock function for the type MockService func (_mock *MockService) GlobalRegistryCreate(registry *model.Registry) error { ret := _mock.Called(registry) if len(ret) == 0 { panic("no return value specified for GlobalRegistryCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok { r0 = returnFunc(registry) } else { r0 = ret.Error(0) } return r0 } // MockService_GlobalRegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryCreate' type MockService_GlobalRegistryCreate_Call struct { *mock.Call } // GlobalRegistryCreate is a helper method to define mock.On call // - registry *model.Registry func (_e *MockService_Expecter) GlobalRegistryCreate(registry interface{}) *MockService_GlobalRegistryCreate_Call { return &MockService_GlobalRegistryCreate_Call{Call: _e.mock.On("GlobalRegistryCreate", registry)} } func (_c *MockService_GlobalRegistryCreate_Call) Run(run func(registry *model.Registry)) *MockService_GlobalRegistryCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Registry if args[0] != nil { arg0 = args[0].(*model.Registry) } run( arg0, ) }) return _c } func (_c *MockService_GlobalRegistryCreate_Call) Return(err error) *MockService_GlobalRegistryCreate_Call { _c.Call.Return(err) return _c } func (_c *MockService_GlobalRegistryCreate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockService_GlobalRegistryCreate_Call { _c.Call.Return(run) return _c } // GlobalRegistryDelete provides a mock function for the type MockService func (_mock *MockService) GlobalRegistryDelete(s string) error { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalRegistryDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(string) error); ok { r0 = returnFunc(s) } else { r0 = ret.Error(0) } return r0 } // MockService_GlobalRegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryDelete' type MockService_GlobalRegistryDelete_Call struct { *mock.Call } // GlobalRegistryDelete is a helper method to define mock.On call // - s string func (_e *MockService_Expecter) GlobalRegistryDelete(s interface{}) *MockService_GlobalRegistryDelete_Call { return &MockService_GlobalRegistryDelete_Call{Call: _e.mock.On("GlobalRegistryDelete", s)} } func (_c *MockService_GlobalRegistryDelete_Call) Run(run func(s string)) *MockService_GlobalRegistryDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockService_GlobalRegistryDelete_Call) Return(err error) *MockService_GlobalRegistryDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_GlobalRegistryDelete_Call) RunAndReturn(run func(s string) error) *MockService_GlobalRegistryDelete_Call { _c.Call.Return(run) return _c } // GlobalRegistryFind provides a mock function for the type MockService func (_mock *MockService) GlobalRegistryFind(s string) (*model.Registry, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalRegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Registry); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_GlobalRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryFind' type MockService_GlobalRegistryFind_Call struct { *mock.Call } // GlobalRegistryFind is a helper method to define mock.On call // - s string func (_e *MockService_Expecter) GlobalRegistryFind(s interface{}) *MockService_GlobalRegistryFind_Call { return &MockService_GlobalRegistryFind_Call{Call: _e.mock.On("GlobalRegistryFind", s)} } func (_c *MockService_GlobalRegistryFind_Call) Run(run func(s string)) *MockService_GlobalRegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockService_GlobalRegistryFind_Call) Return(registry *model.Registry, err error) *MockService_GlobalRegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockService_GlobalRegistryFind_Call) RunAndReturn(run func(s string) (*model.Registry, error)) *MockService_GlobalRegistryFind_Call { _c.Call.Return(run) return _c } // GlobalRegistryList provides a mock function for the type MockService func (_mock *MockService) GlobalRegistryList(listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(listOptions) if len(ret) == 0 { panic("no return value specified for GlobalRegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok { r0 = returnFunc(listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList' type MockService_GlobalRegistryList_Call struct { *mock.Call } // GlobalRegistryList is a helper method to define mock.On call // - listOptions *model.ListOptions func (_e *MockService_Expecter) GlobalRegistryList(listOptions interface{}) *MockService_GlobalRegistryList_Call { return &MockService_GlobalRegistryList_Call{Call: _e.mock.On("GlobalRegistryList", listOptions)} } func (_c *MockService_GlobalRegistryList_Call) Run(run func(listOptions *model.ListOptions)) *MockService_GlobalRegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockService_GlobalRegistryList_Call) Return(registrys []*model.Registry, err error) *MockService_GlobalRegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockService_GlobalRegistryList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Registry, error)) *MockService_GlobalRegistryList_Call { _c.Call.Return(run) return _c } // GlobalRegistryUpdate provides a mock function for the type MockService func (_mock *MockService) GlobalRegistryUpdate(registry *model.Registry) error { ret := _mock.Called(registry) if len(ret) == 0 { panic("no return value specified for GlobalRegistryUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok { r0 = returnFunc(registry) } else { r0 = ret.Error(0) } return r0 } // MockService_GlobalRegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryUpdate' type MockService_GlobalRegistryUpdate_Call struct { *mock.Call } // GlobalRegistryUpdate is a helper method to define mock.On call // - registry *model.Registry func (_e *MockService_Expecter) GlobalRegistryUpdate(registry interface{}) *MockService_GlobalRegistryUpdate_Call { return &MockService_GlobalRegistryUpdate_Call{Call: _e.mock.On("GlobalRegistryUpdate", registry)} } func (_c *MockService_GlobalRegistryUpdate_Call) Run(run func(registry *model.Registry)) *MockService_GlobalRegistryUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Registry if args[0] != nil { arg0 = args[0].(*model.Registry) } run( arg0, ) }) return _c } func (_c *MockService_GlobalRegistryUpdate_Call) Return(err error) *MockService_GlobalRegistryUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockService_GlobalRegistryUpdate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockService_GlobalRegistryUpdate_Call { _c.Call.Return(run) return _c } // OrgRegistryCreate provides a mock function for the type MockService func (_mock *MockService) OrgRegistryCreate(n int64, registry *model.Registry) error { ret := _mock.Called(n, registry) if len(ret) == 0 { panic("no return value specified for OrgRegistryCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, *model.Registry) error); ok { r0 = returnFunc(n, registry) } else { r0 = ret.Error(0) } return r0 } // MockService_OrgRegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryCreate' type MockService_OrgRegistryCreate_Call struct { *mock.Call } // OrgRegistryCreate is a helper method to define mock.On call // - n int64 // - registry *model.Registry func (_e *MockService_Expecter) OrgRegistryCreate(n interface{}, registry interface{}) *MockService_OrgRegistryCreate_Call { return &MockService_OrgRegistryCreate_Call{Call: _e.mock.On("OrgRegistryCreate", n, registry)} } func (_c *MockService_OrgRegistryCreate_Call) Run(run func(n int64, registry *model.Registry)) *MockService_OrgRegistryCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.Registry if args[1] != nil { arg1 = args[1].(*model.Registry) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgRegistryCreate_Call) Return(err error) *MockService_OrgRegistryCreate_Call { _c.Call.Return(err) return _c } func (_c *MockService_OrgRegistryCreate_Call) RunAndReturn(run func(n int64, registry *model.Registry) error) *MockService_OrgRegistryCreate_Call { _c.Call.Return(run) return _c } // OrgRegistryDelete provides a mock function for the type MockService func (_mock *MockService) OrgRegistryDelete(n int64, s string) error { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for OrgRegistryDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, string) error); ok { r0 = returnFunc(n, s) } else { r0 = ret.Error(0) } return r0 } // MockService_OrgRegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryDelete' type MockService_OrgRegistryDelete_Call struct { *mock.Call } // OrgRegistryDelete is a helper method to define mock.On call // - n int64 // - s string func (_e *MockService_Expecter) OrgRegistryDelete(n interface{}, s interface{}) *MockService_OrgRegistryDelete_Call { return &MockService_OrgRegistryDelete_Call{Call: _e.mock.On("OrgRegistryDelete", n, s)} } func (_c *MockService_OrgRegistryDelete_Call) Run(run func(n int64, s string)) *MockService_OrgRegistryDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgRegistryDelete_Call) Return(err error) *MockService_OrgRegistryDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_OrgRegistryDelete_Call) RunAndReturn(run func(n int64, s string) error) *MockService_OrgRegistryDelete_Call { _c.Call.Return(run) return _c } // OrgRegistryFind provides a mock function for the type MockService func (_mock *MockService) OrgRegistryFind(n int64, s string) (*model.Registry, error) { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for OrgRegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Registry, error)); ok { return returnFunc(n, s) } if returnFunc, ok := ret.Get(0).(func(int64, string) *model.Registry); ok { r0 = returnFunc(n, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(int64, string) error); ok { r1 = returnFunc(n, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_OrgRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryFind' type MockService_OrgRegistryFind_Call struct { *mock.Call } // OrgRegistryFind is a helper method to define mock.On call // - n int64 // - s string func (_e *MockService_Expecter) OrgRegistryFind(n interface{}, s interface{}) *MockService_OrgRegistryFind_Call { return &MockService_OrgRegistryFind_Call{Call: _e.mock.On("OrgRegistryFind", n, s)} } func (_c *MockService_OrgRegistryFind_Call) Run(run func(n int64, s string)) *MockService_OrgRegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgRegistryFind_Call) Return(registry *model.Registry, err error) *MockService_OrgRegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockService_OrgRegistryFind_Call) RunAndReturn(run func(n int64, s string) (*model.Registry, error)) *MockService_OrgRegistryFind_Call { _c.Call.Return(run) return _c } // OrgRegistryList provides a mock function for the type MockService func (_mock *MockService) OrgRegistryList(n int64, listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(n, listOptions) if len(ret) == 0 { panic("no return value specified for OrgRegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(n, listOptions) } if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Registry); ok { r0 = returnFunc(n, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { r1 = returnFunc(n, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_OrgRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryList' type MockService_OrgRegistryList_Call struct { *mock.Call } // OrgRegistryList is a helper method to define mock.On call // - n int64 // - listOptions *model.ListOptions func (_e *MockService_Expecter) OrgRegistryList(n interface{}, listOptions interface{}) *MockService_OrgRegistryList_Call { return &MockService_OrgRegistryList_Call{Call: _e.mock.On("OrgRegistryList", n, listOptions)} } func (_c *MockService_OrgRegistryList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockService_OrgRegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgRegistryList_Call) Return(registrys []*model.Registry, err error) *MockService_OrgRegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockService_OrgRegistryList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockService_OrgRegistryList_Call { _c.Call.Return(run) return _c } // OrgRegistryUpdate provides a mock function for the type MockService func (_mock *MockService) OrgRegistryUpdate(n int64, registry *model.Registry) error { ret := _mock.Called(n, registry) if len(ret) == 0 { panic("no return value specified for OrgRegistryUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, *model.Registry) error); ok { r0 = returnFunc(n, registry) } else { r0 = ret.Error(0) } return r0 } // MockService_OrgRegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryUpdate' type MockService_OrgRegistryUpdate_Call struct { *mock.Call } // OrgRegistryUpdate is a helper method to define mock.On call // - n int64 // - registry *model.Registry func (_e *MockService_Expecter) OrgRegistryUpdate(n interface{}, registry interface{}) *MockService_OrgRegistryUpdate_Call { return &MockService_OrgRegistryUpdate_Call{Call: _e.mock.On("OrgRegistryUpdate", n, registry)} } func (_c *MockService_OrgRegistryUpdate_Call) Run(run func(n int64, registry *model.Registry)) *MockService_OrgRegistryUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.Registry if args[1] != nil { arg1 = args[1].(*model.Registry) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgRegistryUpdate_Call) Return(err error) *MockService_OrgRegistryUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockService_OrgRegistryUpdate_Call) RunAndReturn(run func(n int64, registry *model.Registry) error) *MockService_OrgRegistryUpdate_Call { _c.Call.Return(run) return _c } // RegistryCreate provides a mock function for the type MockService func (_mock *MockService) RegistryCreate(repo *model.Repo, registry *model.Registry) error { ret := _mock.Called(repo, registry) if len(ret) == 0 { panic("no return value specified for RegistryCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Registry) error); ok { r0 = returnFunc(repo, registry) } else { r0 = ret.Error(0) } return r0 } // MockService_RegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryCreate' type MockService_RegistryCreate_Call struct { *mock.Call } // RegistryCreate is a helper method to define mock.On call // - repo *model.Repo // - registry *model.Registry func (_e *MockService_Expecter) RegistryCreate(repo interface{}, registry interface{}) *MockService_RegistryCreate_Call { return &MockService_RegistryCreate_Call{Call: _e.mock.On("RegistryCreate", repo, registry)} } func (_c *MockService_RegistryCreate_Call) Run(run func(repo *model.Repo, registry *model.Registry)) *MockService_RegistryCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.Registry if args[1] != nil { arg1 = args[1].(*model.Registry) } run( arg0, arg1, ) }) return _c } func (_c *MockService_RegistryCreate_Call) Return(err error) *MockService_RegistryCreate_Call { _c.Call.Return(err) return _c } func (_c *MockService_RegistryCreate_Call) RunAndReturn(run func(repo *model.Repo, registry *model.Registry) error) *MockService_RegistryCreate_Call { _c.Call.Return(run) return _c } // RegistryDelete provides a mock function for the type MockService func (_mock *MockService) RegistryDelete(repo *model.Repo, s string) error { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for RegistryDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) error); ok { r0 = returnFunc(repo, s) } else { r0 = ret.Error(0) } return r0 } // MockService_RegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryDelete' type MockService_RegistryDelete_Call struct { *mock.Call } // RegistryDelete is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockService_Expecter) RegistryDelete(repo interface{}, s interface{}) *MockService_RegistryDelete_Call { return &MockService_RegistryDelete_Call{Call: _e.mock.On("RegistryDelete", repo, s)} } func (_c *MockService_RegistryDelete_Call) Run(run func(repo *model.Repo, s string)) *MockService_RegistryDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_RegistryDelete_Call) Return(err error) *MockService_RegistryDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_RegistryDelete_Call) RunAndReturn(run func(repo *model.Repo, s string) error) *MockService_RegistryDelete_Call { _c.Call.Return(run) return _c } // RegistryFind provides a mock function for the type MockService func (_mock *MockService) RegistryFind(repo *model.Repo, s string) (*model.Registry, error) { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for RegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Registry, error)); ok { return returnFunc(repo, s) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Registry); ok { r0 = returnFunc(repo, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok { r1 = returnFunc(repo, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_RegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryFind' type MockService_RegistryFind_Call struct { *mock.Call } // RegistryFind is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockService_Expecter) RegistryFind(repo interface{}, s interface{}) *MockService_RegistryFind_Call { return &MockService_RegistryFind_Call{Call: _e.mock.On("RegistryFind", repo, s)} } func (_c *MockService_RegistryFind_Call) Run(run func(repo *model.Repo, s string)) *MockService_RegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_RegistryFind_Call) Return(registry *model.Registry, err error) *MockService_RegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockService_RegistryFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Registry, error)) *MockService_RegistryFind_Call { _c.Call.Return(run) return _c } // RegistryList provides a mock function for the type MockService func (_mock *MockService) RegistryList(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(repo, listOptions) if len(ret) == 0 { panic("no return value specified for RegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(repo, listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Registry); ok { r0 = returnFunc(repo, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok { r1 = returnFunc(repo, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_RegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryList' type MockService_RegistryList_Call struct { *mock.Call } // RegistryList is a helper method to define mock.On call // - repo *model.Repo // - listOptions *model.ListOptions func (_e *MockService_Expecter) RegistryList(repo interface{}, listOptions interface{}) *MockService_RegistryList_Call { return &MockService_RegistryList_Call{Call: _e.mock.On("RegistryList", repo, listOptions)} } func (_c *MockService_RegistryList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions)) *MockService_RegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockService_RegistryList_Call) Return(registrys []*model.Registry, err error) *MockService_RegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockService_RegistryList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockService_RegistryList_Call { _c.Call.Return(run) return _c } // RegistryListPipeline provides a mock function for the type MockService func (_mock *MockService) RegistryListPipeline(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) { ret := _mock.Called(context1, repo, pipeline, netrc) if len(ret) == 0 { panic("no return value specified for RegistryListPipeline") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Registry, error)); ok { return returnFunc(context1, repo, pipeline, netrc) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) []*model.Registry); ok { r0 = returnFunc(context1, repo, pipeline, netrc) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) error); ok { r1 = returnFunc(context1, repo, pipeline, netrc) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_RegistryListPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryListPipeline' type MockService_RegistryListPipeline_Call struct { *mock.Call } // RegistryListPipeline is a helper method to define mock.On call // - context1 context.Context // - repo *model.Repo // - pipeline *model.Pipeline // - netrc *model.Netrc func (_e *MockService_Expecter) RegistryListPipeline(context1 interface{}, repo interface{}, pipeline interface{}, netrc interface{}) *MockService_RegistryListPipeline_Call { return &MockService_RegistryListPipeline_Call{Call: _e.mock.On("RegistryListPipeline", context1, repo, pipeline, netrc)} } func (_c *MockService_RegistryListPipeline_Call) Run(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc)) *MockService_RegistryListPipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.Repo if args[1] != nil { arg1 = args[1].(*model.Repo) } var arg2 *model.Pipeline if args[2] != nil { arg2 = args[2].(*model.Pipeline) } var arg3 *model.Netrc if args[3] != nil { arg3 = args[3].(*model.Netrc) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockService_RegistryListPipeline_Call) Return(registrys []*model.Registry, err error) *MockService_RegistryListPipeline_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockService_RegistryListPipeline_Call) RunAndReturn(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error)) *MockService_RegistryListPipeline_Call { _c.Call.Return(run) return _c } // RegistryUpdate provides a mock function for the type MockService func (_mock *MockService) RegistryUpdate(repo *model.Repo, registry *model.Registry) error { ret := _mock.Called(repo, registry) if len(ret) == 0 { panic("no return value specified for RegistryUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Registry) error); ok { r0 = returnFunc(repo, registry) } else { r0 = ret.Error(0) } return r0 } // MockService_RegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryUpdate' type MockService_RegistryUpdate_Call struct { *mock.Call } // RegistryUpdate is a helper method to define mock.On call // - repo *model.Repo // - registry *model.Registry func (_e *MockService_Expecter) RegistryUpdate(repo interface{}, registry interface{}) *MockService_RegistryUpdate_Call { return &MockService_RegistryUpdate_Call{Call: _e.mock.On("RegistryUpdate", repo, registry)} } func (_c *MockService_RegistryUpdate_Call) Run(run func(repo *model.Repo, registry *model.Registry)) *MockService_RegistryUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.Registry if args[1] != nil { arg1 = args[1].(*model.Registry) } run( arg0, arg1, ) }) return _c } func (_c *MockService_RegistryUpdate_Call) Return(err error) *MockService_RegistryUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockService_RegistryUpdate_Call) RunAndReturn(run func(repo *model.Repo, registry *model.Registry) error) *MockService_RegistryUpdate_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/services/registry/service.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // Service defines a service for managing registries. type Service interface { RegistryListPipeline(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Registry, error) // Repository registries RegistryFind(*model.Repo, string) (*model.Registry, error) RegistryList(*model.Repo, *model.ListOptions) ([]*model.Registry, error) RegistryCreate(*model.Repo, *model.Registry) error RegistryUpdate(*model.Repo, *model.Registry) error RegistryDelete(*model.Repo, string) error // Organization registries OrgRegistryFind(int64, string) (*model.Registry, error) OrgRegistryList(int64, *model.ListOptions) ([]*model.Registry, error) OrgRegistryCreate(int64, *model.Registry) error OrgRegistryUpdate(int64, *model.Registry) error OrgRegistryDelete(int64, string) error // Global registries GlobalRegistryFind(string) (*model.Registry, error) GlobalRegistryList(*model.ListOptions) ([]*model.Registry, error) GlobalRegistryCreate(*model.Registry) error GlobalRegistryUpdate(*model.Registry) error GlobalRegistryDelete(string) error } // ReadOnlyService defines a service for managing registries. type ReadOnlyService interface { GlobalRegistryFind(string) (*model.Registry, error) GlobalRegistryList(*model.ListOptions) ([]*model.Registry, error) } ================================================ FILE: server/services/registry/with_extension.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "context" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type withExtension struct { base Service extension *httpExtension } // NewWithExtension returns a registry service that combines a base service with an HTTP extension. // The extension is called during RegistryListPipeline to fetch additional registry credentials and // the extension registries taking priority. func NewWithExtension(base Service, extension *httpExtension) Service { return &withExtension{base, extension} } func (w *withExtension) RegistryListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Registry, error) { // Get registries from base service baseRegistries, err := w.base.RegistryListPipeline(ctx, repo, pipeline, netrc) if err != nil { return nil, err } // Get registries from HTTP extension extensionRegistries, err := w.extension.RegistryListPipeline(ctx, repo, pipeline, netrc) if err != nil { // Log the error but don't fail - use base registries only log.Warn().Err(err).Msg("failed to fetch registries from extension") return baseRegistries, nil } if len(extensionRegistries) == 0 { return baseRegistries, nil } // Merge registries, with extension registries taking priority (no duplicates by address) exists := make(map[string]struct{}, len(extensionRegistries)) for _, reg := range extensionRegistries { exists[reg.Address] = struct{}{} } merged := make([]*model.Registry, 0, len(baseRegistries)+len(extensionRegistries)) merged = append(merged, extensionRegistries...) for _, reg := range baseRegistries { if _, ok := exists[reg.Address]; ok { continue } exists[reg.Address] = struct{}{} merged = append(merged, reg) } return merged, nil } // All other methods delegate to the base service. func (w *withExtension) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { return w.base.RegistryFind(repo, addr) } func (w *withExtension) RegistryList(repo *model.Repo, p *model.ListOptions) ([]*model.Registry, error) { return w.base.RegistryList(repo, p) } func (w *withExtension) RegistryCreate(repo *model.Repo, registry *model.Registry) error { return w.base.RegistryCreate(repo, registry) } func (w *withExtension) RegistryUpdate(repo *model.Repo, registry *model.Registry) error { return w.base.RegistryUpdate(repo, registry) } func (w *withExtension) RegistryDelete(repo *model.Repo, addr string) error { return w.base.RegistryDelete(repo, addr) } func (w *withExtension) OrgRegistryFind(owner int64, addr string) (*model.Registry, error) { return w.base.OrgRegistryFind(owner, addr) } func (w *withExtension) OrgRegistryList(owner int64, p *model.ListOptions) ([]*model.Registry, error) { return w.base.OrgRegistryList(owner, p) } func (w *withExtension) OrgRegistryCreate(owner int64, registry *model.Registry) error { return w.base.OrgRegistryCreate(owner, registry) } func (w *withExtension) OrgRegistryUpdate(owner int64, registry *model.Registry) error { return w.base.OrgRegistryUpdate(owner, registry) } func (w *withExtension) OrgRegistryDelete(owner int64, addr string) error { return w.base.OrgRegistryDelete(owner, addr) } func (w *withExtension) GlobalRegistryFind(addr string) (*model.Registry, error) { return w.base.GlobalRegistryFind(addr) } func (w *withExtension) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { return w.base.GlobalRegistryList(p) } func (w *withExtension) GlobalRegistryCreate(registry *model.Registry) error { return w.base.GlobalRegistryCreate(registry) } func (w *withExtension) GlobalRegistryUpdate(registry *model.Registry) error { return w.base.GlobalRegistryUpdate(registry) } func (w *withExtension) GlobalRegistryDelete(addr string) error { return w.base.GlobalRegistryDelete(addr) } ================================================ FILE: server/services/registry/with_extension_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package registry import ( "crypto/ed25519" "crypto/rand" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/yaronf/httpsign" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestWithExtensionRegistryListPipeline(t *testing.T) { t.Parallel() testTable := []struct { name string repoName string dbRegs []*model.Registry expected []*model.Registry expectedError bool }{ { name: "Extension overrides base registry by name", repoName: "override-test", dbRegs: []*model.Registry{ {ID: 1, RepoID: 1, Address: "docker.io", Username: "shared", Password: "db-value"}, {ID: 2, RepoID: 1, Address: "quay.io", Username: "db-only", Password: "only-in-db"}, }, expected: []*model.Registry{ {ID: 2, RepoID: 1, Address: "quay.io", Username: "db-only", Password: "only-in-db"}, {Address: "docker.io", Username: "shared", Password: "external-value"}, {Address: "codeberg.org", Username: "ext-only", Password: "only-in-ext"}, }, expectedError: false, }, { name: "Extension returns 204 no registries", repoName: "no-content", dbRegs: []*model.Registry{ {ID: 1, RepoID: 1, Address: "quay.io", Username: "db-secret", Password: "db-value"}, }, expected: []*model.Registry{ {ID: 1, RepoID: 1, Address: "quay.io", Username: "db-secret", Password: "db-value"}, }, expectedError: false, }, { name: "Extension error falls back to base registries", repoName: "server-error", dbRegs: []*model.Registry{ {ID: 1, RepoID: 1, Address: "quay.io", Username: "db-secret", Password: "db-value"}, }, expected: []*model.Registry{ {ID: 1, RepoID: 1, Address: "quay.io", Username: "db-secret", Password: "db-value"}, }, expectedError: false, }, } pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err, "can't generate ed25519 keypair") fixtureHandler := func(w http.ResponseWriter, r *http.Request) { // check signature pubKeyID := "woodpecker-ci-extensions" verifier, err := httpsign.NewEd25519Verifier(pubEd25519Key, httpsign.NewVerifyConfig(), httpsign.Headers("@request-target", "content-digest")) if err != nil { http.Error(w, "can't create verifier", http.StatusInternalServerError) return } err = httpsign.VerifyRequest(pubKeyID, *verifier, r) if err != nil { http.Error(w, "Invalid signature", http.StatusBadRequest) return } type incoming struct { Repo *model.Repo `json:"repo"` Pipeline *model.Pipeline `json:"pipeline"` Netrc *model.Netrc `json:"netrc"` } var req incoming body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "can't read body", http.StatusBadRequest) return } err = json.Unmarshal(body, &req) if err != nil { http.Error(w, "Failed to parse JSON"+err.Error(), http.StatusBadRequest) return } switch req.Repo.Name { case "no-content": w.WriteHeader(http.StatusNoContent) return case "server-error": w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "registries": []*model.Registry{ {Address: "docker.io", Username: "shared", Password: "external-value"}, {Address: "codeberg.org", Username: "ext-only", Password: "only-in-ext"}, }, })) } ts := httptest.NewServer(http.HandlerFunc(fixtureHandler)) defer ts.Close() client, err := utils.NewHTTPClient(privEd25519Key, "loopback") require.NoError(t, err) httpExtension := NewHTTP(ts.URL, client, true) for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("RegistryList", mock.Anything, true, mock.Anything).Return(tt.dbRegs, nil) combined := NewWithExtension(NewDB(mockStore), httpExtension) registries, err := combined.RegistryListPipeline( t.Context(), &model.Repo{ID: 1, Name: tt.repoName}, &model.Pipeline{}, nil, ) if tt.expectedError { require.Error(t, err, "expected an error") } else { require.NoError(t, err, "error fetching registries") } assert.ElementsMatch(t, tt.expected, registries, "expected some other registries") }) } } ================================================ FILE: server/services/secret/combined.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type combined struct { base Service extension *httpExtension } // NewCombined returns a secret service that combines a base service with an HTTP extension. // The extension is called during SecretListPipeline to fetch additional secrets and // the extension secrets taking priority. func NewCombined(base Service, extension *httpExtension) Service { return &combined{base, extension} } func (c *combined) SecretListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) { // Get secrets from base service baseSecrets, err := c.base.SecretListPipeline(ctx, repo, pipeline, netrc) if err != nil { return nil, err } // Get secrets from HTTP extension extensionSecrets, err := c.extension.SecretListPipeline(ctx, repo, pipeline, netrc) if err != nil { // Log the error but don't fail - use base secrets only log.Warn().Err(err).Msg("failed to fetch secrets from extension") return baseSecrets, nil } if len(extensionSecrets) == 0 { return baseSecrets, nil } // Merge secrets, with extension secrets taking priority (no duplicates by name) exists := make(map[string]struct{}, len(extensionSecrets)) for _, s := range extensionSecrets { exists[s.Name] = struct{}{} } merged := make([]*model.Secret, 0, len(baseSecrets)+len(extensionSecrets)) merged = append(merged, extensionSecrets...) for _, s := range baseSecrets { if _, ok := exists[s.Name]; ok { continue } exists[s.Name] = struct{}{} merged = append(merged, s) } return merged, nil } // All other methods delegate to the base service. func (c *combined) SecretFind(repo *model.Repo, name string) (*model.Secret, error) { return c.base.SecretFind(repo, name) } func (c *combined) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret, error) { return c.base.SecretList(repo, p) } func (c *combined) SecretCreate(repo *model.Repo, secret *model.Secret) error { return c.base.SecretCreate(repo, secret) } func (c *combined) SecretUpdate(repo *model.Repo, secret *model.Secret) error { return c.base.SecretUpdate(repo, secret) } func (c *combined) SecretDelete(repo *model.Repo, name string) error { return c.base.SecretDelete(repo, name) } func (c *combined) OrgSecretFind(orgID int64, name string) (*model.Secret, error) { return c.base.OrgSecretFind(orgID, name) } func (c *combined) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) { return c.base.OrgSecretList(orgID, p) } func (c *combined) OrgSecretCreate(orgID int64, secret *model.Secret) error { return c.base.OrgSecretCreate(orgID, secret) } func (c *combined) OrgSecretUpdate(orgID int64, secret *model.Secret) error { return c.base.OrgSecretUpdate(orgID, secret) } func (c *combined) OrgSecretDelete(orgID int64, name string) error { return c.base.OrgSecretDelete(orgID, name) } func (c *combined) GlobalSecretFind(name string) (*model.Secret, error) { return c.base.GlobalSecretFind(name) } func (c *combined) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) { return c.base.GlobalSecretList(p) } func (c *combined) GlobalSecretCreate(secret *model.Secret) error { return c.base.GlobalSecretCreate(secret) } func (c *combined) GlobalSecretUpdate(secret *model.Secret) error { return c.base.GlobalSecretUpdate(secret) } func (c *combined) GlobalSecretDelete(name string) error { return c.base.GlobalSecretDelete(name) } ================================================ FILE: server/services/secret/combined_test.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret_test import ( "crypto/ed25519" "crypto/rand" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/yaronf/httpsign" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) func TestCombinedSecretListPipeline(t *testing.T) { t.Parallel() testTable := []struct { name string repoName string dbSecrets []*model.Secret expected []*model.Secret expectedError bool }{ { name: "Extension overrides base secret by name", repoName: "override-test", dbSecrets: []*model.Secret{ {ID: 1, RepoID: 1, Name: "shared", Value: "db-value"}, {ID: 2, RepoID: 1, Name: "db-only", Value: "only-in-db"}, }, expected: []*model.Secret{ {Name: "shared", Value: "external-value"}, {Name: "ext-only", Value: "only-in-ext"}, {ID: 2, RepoID: 1, Name: "db-only", Value: "only-in-db"}, }, expectedError: false, }, { name: "Extension returns 204 no secrets", repoName: "no-content", dbSecrets: []*model.Secret{ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"}, }, expected: []*model.Secret{ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"}, }, expectedError: false, }, { name: "Extension error falls back to base secrets", repoName: "server-error", dbSecrets: []*model.Secret{ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"}, }, expected: []*model.Secret{ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"}, }, expectedError: false, }, } pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err, "can't generate ed25519 keypair") fixtureHandler := func(w http.ResponseWriter, r *http.Request) { // check signature pubKeyID := "woodpecker-ci-extensions" verifier, err := httpsign.NewEd25519Verifier(pubEd25519Key, httpsign.NewVerifyConfig(), httpsign.Headers("@request-target", "content-digest")) if err != nil { http.Error(w, "can't create verifier", http.StatusInternalServerError) return } err = httpsign.VerifyRequest(pubKeyID, *verifier, r) if err != nil { http.Error(w, "Invalid signature", http.StatusBadRequest) return } type incoming struct { Repo *model.Repo `json:"repo"` Pipeline *model.Pipeline `json:"pipeline"` Netrc *model.Netrc `json:"netrc"` } var req incoming body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "can't read body", http.StatusBadRequest) return } err = json.Unmarshal(body, &req) if err != nil { http.Error(w, "Failed to parse JSON"+err.Error(), http.StatusBadRequest) return } switch req.Repo.Name { case "no-content": w.WriteHeader(http.StatusNoContent) return case "server-error": w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "secrets": []*model.Secret{ {Name: "shared", Value: "external-value"}, {Name: "ext-only", Value: "only-in-ext"}, }, })) } ts := httptest.NewServer(http.HandlerFunc(fixtureHandler)) defer ts.Close() client, err := utils.NewHTTPClient(privEd25519Key, "loopback") require.NoError(t, err) httpExtension := secret.NewHTTP(ts.URL, client, true) for _, tt := range testTable { t.Run(tt.name, func(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("SecretList", mock.Anything, true, mock.Anything).Return(tt.dbSecrets, nil) combined := secret.NewCombined(secret.NewDB(mockStore), httpExtension) secrets, err := combined.SecretListPipeline( t.Context(), &model.Repo{ID: 1, Name: tt.repoName}, &model.Pipeline{}, nil, ) if tt.expectedError { require.Error(t, err, "expected an error") } else { require.NoError(t, err, "error fetching secrets") } assert.ElementsMatch(t, tt.expected, secrets, "expected some other secrets") }) } } ================================================ FILE: server/services/secret/db.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store" ) type db struct { store store.Store } // NewDB returns a new local secret service. func NewDB(store store.Store) Service { return &db{store: store} } func (d *db) SecretFind(repo *model.Repo, name string) (*model.Secret, error) { return d.store.SecretFind(repo, name) } func (d *db) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret, error) { return d.store.SecretList(repo, false, p) } func (d *db) SecretListPipeline(_ context.Context, repo *model.Repo, _ *model.Pipeline, _ *model.Netrc) ([]*model.Secret, error) { s, err := d.store.SecretList(repo, true, &model.ListOptions{All: true}) if err != nil { return nil, err } // Return only secrets with unique name // Priority order in case of duplicate names are repository, user/organization, global secrets := make([]*model.Secret, 0, len(s)) uniq := make(map[string]struct{}) for _, condition := range []struct { IsRepository bool IsOrganization bool IsGlobal bool }{ {IsRepository: true}, {IsOrganization: true}, {IsGlobal: true}, } { for _, secret := range s { if secret.IsRepository() != condition.IsRepository || secret.IsOrganization() != condition.IsOrganization || secret.IsGlobal() != condition.IsGlobal { continue } if _, ok := uniq[secret.Name]; ok { continue } uniq[secret.Name] = struct{}{} secrets = append(secrets, secret) } } return secrets, nil } func (d *db) SecretCreate(_ *model.Repo, in *model.Secret) error { return d.store.SecretCreate(in) } func (d *db) SecretUpdate(_ *model.Repo, in *model.Secret) error { return d.store.SecretUpdate(in) } func (d *db) SecretDelete(repo *model.Repo, name string) error { secret, err := d.store.SecretFind(repo, name) if err != nil { return err } return d.store.SecretDelete(secret) } func (d *db) OrgSecretFind(owner int64, name string) (*model.Secret, error) { return d.store.OrgSecretFind(owner, name) } func (d *db) OrgSecretList(owner int64, p *model.ListOptions) ([]*model.Secret, error) { return d.store.OrgSecretList(owner, p) } func (d *db) OrgSecretCreate(_ int64, in *model.Secret) error { return d.store.SecretCreate(in) } func (d *db) OrgSecretUpdate(_ int64, in *model.Secret) error { return d.store.SecretUpdate(in) } func (d *db) OrgSecretDelete(owner int64, name string) error { secret, err := d.store.OrgSecretFind(owner, name) if err != nil { return err } return d.store.SecretDelete(secret) } func (d *db) GlobalSecretFind(owner string) (*model.Secret, error) { return d.store.GlobalSecretFind(owner) } func (d *db) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) { return d.store.GlobalSecretList(p) } func (d *db) GlobalSecretCreate(in *model.Secret) error { return d.store.SecretCreate(in) } func (d *db) GlobalSecretUpdate(in *model.Secret) error { return d.store.SecretUpdate(in) } func (d *db) GlobalSecretDelete(name string) error { secret, err := d.store.GlobalSecretFind(name) if err != nil { return err } return d.store.SecretDelete(secret) } ================================================ FILE: server/services/secret/db_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks" ) var ( globalSecret = &model.Secret{ ID: 1, OrgID: 0, RepoID: 0, Name: "secret", Value: "value-global", } orgSecret = &model.Secret{ ID: 2, OrgID: 1, RepoID: 0, Name: "secret", Value: "value-org", } repoSecret = &model.Secret{ ID: 3, OrgID: 0, RepoID: 1, Name: "secret", Value: "value-repo", } ) func TestSecretListPipeline(t *testing.T) { mockStore := store_mocks.NewMockStore(t) mockStore.On("SecretList", mock.Anything, mock.Anything, mock.Anything).Once().Return([]*model.Secret{ globalSecret, orgSecret, repoSecret, }, nil) s, err := secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil) assert.NoError(t, err) assert.Len(t, s, 1) assert.Equal(t, "value-repo", s[0].Value) mockStore.On("SecretList", mock.Anything, mock.Anything, mock.Anything).Once().Return([]*model.Secret{ globalSecret, orgSecret, }, nil) s, err = secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil) assert.NoError(t, err) assert.Len(t, s, 1) assert.Equal(t, "value-org", s[0].Value) mockStore.On("SecretList", mock.Anything, mock.Anything, mock.Anything).Once().Return([]*model.Secret{ globalSecret, }, nil) s, err = secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil) assert.NoError(t, err) assert.Len(t, s, 1) assert.Equal(t, "value-global", s[0].Value) } ================================================ FILE: server/services/secret/http.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "fmt" "net/http" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" ) type httpExtension struct { endpoint string client *utils.Client includeNetrc bool } type secretRequestStructure struct { Repo *model.Repo `json:"repo"` Pipeline *model.Pipeline `json:"pipeline"` Netrc *model.Netrc `json:"netrc,omitempty"` } type secretResponseStructure struct { Secrets []*model.Secret `json:"secrets"` } // NewHTTP returns a new HTTP secret extension client. func NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) *httpExtension { return &httpExtension{endpoint: endpoint, client: client, includeNetrc: includeNetrc} } // SecretListPipeline fetches secrets from an external HTTP extension. func (h *httpExtension) SecretListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) { body := secretRequestStructure{ Repo: repo, Pipeline: pipeline, } if h.includeNetrc { body.Netrc = netrc } response := new(secretResponseStructure) status, err := h.client.Send(ctx, http.MethodPost, h.endpoint, body, response) if err != nil && status != http.StatusNoContent { return nil, fmt.Errorf("failed to fetch secrets via http (%d) %w", status, err) } if status != http.StatusOK { // 204 No Content means no additional secrets return nil, nil } return response.Secrets, nil } ================================================ FILE: server/services/secret/mocks/mock_Service.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockService(t interface { mock.TestingT Cleanup(func()) }) *MockService { mock := &MockService{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockService is an autogenerated mock type for the Service type type MockService struct { mock.Mock } type MockService_Expecter struct { mock *mock.Mock } func (_m *MockService) EXPECT() *MockService_Expecter { return &MockService_Expecter{mock: &_m.Mock} } // GlobalSecretCreate provides a mock function for the type MockService func (_mock *MockService) GlobalSecretCreate(secret *model.Secret) error { ret := _mock.Called(secret) if len(ret) == 0 { panic("no return value specified for GlobalSecretCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok { r0 = returnFunc(secret) } else { r0 = ret.Error(0) } return r0 } // MockService_GlobalSecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretCreate' type MockService_GlobalSecretCreate_Call struct { *mock.Call } // GlobalSecretCreate is a helper method to define mock.On call // - secret *model.Secret func (_e *MockService_Expecter) GlobalSecretCreate(secret interface{}) *MockService_GlobalSecretCreate_Call { return &MockService_GlobalSecretCreate_Call{Call: _e.mock.On("GlobalSecretCreate", secret)} } func (_c *MockService_GlobalSecretCreate_Call) Run(run func(secret *model.Secret)) *MockService_GlobalSecretCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Secret if args[0] != nil { arg0 = args[0].(*model.Secret) } run( arg0, ) }) return _c } func (_c *MockService_GlobalSecretCreate_Call) Return(err error) *MockService_GlobalSecretCreate_Call { _c.Call.Return(err) return _c } func (_c *MockService_GlobalSecretCreate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockService_GlobalSecretCreate_Call { _c.Call.Return(run) return _c } // GlobalSecretDelete provides a mock function for the type MockService func (_mock *MockService) GlobalSecretDelete(s string) error { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalSecretDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(string) error); ok { r0 = returnFunc(s) } else { r0 = ret.Error(0) } return r0 } // MockService_GlobalSecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretDelete' type MockService_GlobalSecretDelete_Call struct { *mock.Call } // GlobalSecretDelete is a helper method to define mock.On call // - s string func (_e *MockService_Expecter) GlobalSecretDelete(s interface{}) *MockService_GlobalSecretDelete_Call { return &MockService_GlobalSecretDelete_Call{Call: _e.mock.On("GlobalSecretDelete", s)} } func (_c *MockService_GlobalSecretDelete_Call) Run(run func(s string)) *MockService_GlobalSecretDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockService_GlobalSecretDelete_Call) Return(err error) *MockService_GlobalSecretDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_GlobalSecretDelete_Call) RunAndReturn(run func(s string) error) *MockService_GlobalSecretDelete_Call { _c.Call.Return(run) return _c } // GlobalSecretFind provides a mock function for the type MockService func (_mock *MockService) GlobalSecretFind(s string) (*model.Secret, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalSecretFind") } var r0 *model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Secret, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Secret); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_GlobalSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretFind' type MockService_GlobalSecretFind_Call struct { *mock.Call } // GlobalSecretFind is a helper method to define mock.On call // - s string func (_e *MockService_Expecter) GlobalSecretFind(s interface{}) *MockService_GlobalSecretFind_Call { return &MockService_GlobalSecretFind_Call{Call: _e.mock.On("GlobalSecretFind", s)} } func (_c *MockService_GlobalSecretFind_Call) Run(run func(s string)) *MockService_GlobalSecretFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockService_GlobalSecretFind_Call) Return(secret *model.Secret, err error) *MockService_GlobalSecretFind_Call { _c.Call.Return(secret, err) return _c } func (_c *MockService_GlobalSecretFind_Call) RunAndReturn(run func(s string) (*model.Secret, error)) *MockService_GlobalSecretFind_Call { _c.Call.Return(run) return _c } // GlobalSecretList provides a mock function for the type MockService func (_mock *MockService) GlobalSecretList(listOptions *model.ListOptions) ([]*model.Secret, error) { ret := _mock.Called(listOptions) if len(ret) == 0 { panic("no return value specified for GlobalSecretList") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Secret, error)); ok { return returnFunc(listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Secret); ok { r0 = returnFunc(listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_GlobalSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretList' type MockService_GlobalSecretList_Call struct { *mock.Call } // GlobalSecretList is a helper method to define mock.On call // - listOptions *model.ListOptions func (_e *MockService_Expecter) GlobalSecretList(listOptions interface{}) *MockService_GlobalSecretList_Call { return &MockService_GlobalSecretList_Call{Call: _e.mock.On("GlobalSecretList", listOptions)} } func (_c *MockService_GlobalSecretList_Call) Run(run func(listOptions *model.ListOptions)) *MockService_GlobalSecretList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockService_GlobalSecretList_Call) Return(secrets []*model.Secret, err error) *MockService_GlobalSecretList_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockService_GlobalSecretList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Secret, error)) *MockService_GlobalSecretList_Call { _c.Call.Return(run) return _c } // GlobalSecretUpdate provides a mock function for the type MockService func (_mock *MockService) GlobalSecretUpdate(secret *model.Secret) error { ret := _mock.Called(secret) if len(ret) == 0 { panic("no return value specified for GlobalSecretUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok { r0 = returnFunc(secret) } else { r0 = ret.Error(0) } return r0 } // MockService_GlobalSecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretUpdate' type MockService_GlobalSecretUpdate_Call struct { *mock.Call } // GlobalSecretUpdate is a helper method to define mock.On call // - secret *model.Secret func (_e *MockService_Expecter) GlobalSecretUpdate(secret interface{}) *MockService_GlobalSecretUpdate_Call { return &MockService_GlobalSecretUpdate_Call{Call: _e.mock.On("GlobalSecretUpdate", secret)} } func (_c *MockService_GlobalSecretUpdate_Call) Run(run func(secret *model.Secret)) *MockService_GlobalSecretUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Secret if args[0] != nil { arg0 = args[0].(*model.Secret) } run( arg0, ) }) return _c } func (_c *MockService_GlobalSecretUpdate_Call) Return(err error) *MockService_GlobalSecretUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockService_GlobalSecretUpdate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockService_GlobalSecretUpdate_Call { _c.Call.Return(run) return _c } // OrgSecretCreate provides a mock function for the type MockService func (_mock *MockService) OrgSecretCreate(n int64, secret *model.Secret) error { ret := _mock.Called(n, secret) if len(ret) == 0 { panic("no return value specified for OrgSecretCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, *model.Secret) error); ok { r0 = returnFunc(n, secret) } else { r0 = ret.Error(0) } return r0 } // MockService_OrgSecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretCreate' type MockService_OrgSecretCreate_Call struct { *mock.Call } // OrgSecretCreate is a helper method to define mock.On call // - n int64 // - secret *model.Secret func (_e *MockService_Expecter) OrgSecretCreate(n interface{}, secret interface{}) *MockService_OrgSecretCreate_Call { return &MockService_OrgSecretCreate_Call{Call: _e.mock.On("OrgSecretCreate", n, secret)} } func (_c *MockService_OrgSecretCreate_Call) Run(run func(n int64, secret *model.Secret)) *MockService_OrgSecretCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.Secret if args[1] != nil { arg1 = args[1].(*model.Secret) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgSecretCreate_Call) Return(err error) *MockService_OrgSecretCreate_Call { _c.Call.Return(err) return _c } func (_c *MockService_OrgSecretCreate_Call) RunAndReturn(run func(n int64, secret *model.Secret) error) *MockService_OrgSecretCreate_Call { _c.Call.Return(run) return _c } // OrgSecretDelete provides a mock function for the type MockService func (_mock *MockService) OrgSecretDelete(n int64, s string) error { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for OrgSecretDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, string) error); ok { r0 = returnFunc(n, s) } else { r0 = ret.Error(0) } return r0 } // MockService_OrgSecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretDelete' type MockService_OrgSecretDelete_Call struct { *mock.Call } // OrgSecretDelete is a helper method to define mock.On call // - n int64 // - s string func (_e *MockService_Expecter) OrgSecretDelete(n interface{}, s interface{}) *MockService_OrgSecretDelete_Call { return &MockService_OrgSecretDelete_Call{Call: _e.mock.On("OrgSecretDelete", n, s)} } func (_c *MockService_OrgSecretDelete_Call) Run(run func(n int64, s string)) *MockService_OrgSecretDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgSecretDelete_Call) Return(err error) *MockService_OrgSecretDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_OrgSecretDelete_Call) RunAndReturn(run func(n int64, s string) error) *MockService_OrgSecretDelete_Call { _c.Call.Return(run) return _c } // OrgSecretFind provides a mock function for the type MockService func (_mock *MockService) OrgSecretFind(n int64, s string) (*model.Secret, error) { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for OrgSecretFind") } var r0 *model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Secret, error)); ok { return returnFunc(n, s) } if returnFunc, ok := ret.Get(0).(func(int64, string) *model.Secret); ok { r0 = returnFunc(n, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(int64, string) error); ok { r1 = returnFunc(n, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_OrgSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretFind' type MockService_OrgSecretFind_Call struct { *mock.Call } // OrgSecretFind is a helper method to define mock.On call // - n int64 // - s string func (_e *MockService_Expecter) OrgSecretFind(n interface{}, s interface{}) *MockService_OrgSecretFind_Call { return &MockService_OrgSecretFind_Call{Call: _e.mock.On("OrgSecretFind", n, s)} } func (_c *MockService_OrgSecretFind_Call) Run(run func(n int64, s string)) *MockService_OrgSecretFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgSecretFind_Call) Return(secret *model.Secret, err error) *MockService_OrgSecretFind_Call { _c.Call.Return(secret, err) return _c } func (_c *MockService_OrgSecretFind_Call) RunAndReturn(run func(n int64, s string) (*model.Secret, error)) *MockService_OrgSecretFind_Call { _c.Call.Return(run) return _c } // OrgSecretList provides a mock function for the type MockService func (_mock *MockService) OrgSecretList(n int64, listOptions *model.ListOptions) ([]*model.Secret, error) { ret := _mock.Called(n, listOptions) if len(ret) == 0 { panic("no return value specified for OrgSecretList") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Secret, error)); ok { return returnFunc(n, listOptions) } if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Secret); ok { r0 = returnFunc(n, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { r1 = returnFunc(n, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_OrgSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretList' type MockService_OrgSecretList_Call struct { *mock.Call } // OrgSecretList is a helper method to define mock.On call // - n int64 // - listOptions *model.ListOptions func (_e *MockService_Expecter) OrgSecretList(n interface{}, listOptions interface{}) *MockService_OrgSecretList_Call { return &MockService_OrgSecretList_Call{Call: _e.mock.On("OrgSecretList", n, listOptions)} } func (_c *MockService_OrgSecretList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockService_OrgSecretList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgSecretList_Call) Return(secrets []*model.Secret, err error) *MockService_OrgSecretList_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockService_OrgSecretList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockService_OrgSecretList_Call { _c.Call.Return(run) return _c } // OrgSecretUpdate provides a mock function for the type MockService func (_mock *MockService) OrgSecretUpdate(n int64, secret *model.Secret) error { ret := _mock.Called(n, secret) if len(ret) == 0 { panic("no return value specified for OrgSecretUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, *model.Secret) error); ok { r0 = returnFunc(n, secret) } else { r0 = ret.Error(0) } return r0 } // MockService_OrgSecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretUpdate' type MockService_OrgSecretUpdate_Call struct { *mock.Call } // OrgSecretUpdate is a helper method to define mock.On call // - n int64 // - secret *model.Secret func (_e *MockService_Expecter) OrgSecretUpdate(n interface{}, secret interface{}) *MockService_OrgSecretUpdate_Call { return &MockService_OrgSecretUpdate_Call{Call: _e.mock.On("OrgSecretUpdate", n, secret)} } func (_c *MockService_OrgSecretUpdate_Call) Run(run func(n int64, secret *model.Secret)) *MockService_OrgSecretUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.Secret if args[1] != nil { arg1 = args[1].(*model.Secret) } run( arg0, arg1, ) }) return _c } func (_c *MockService_OrgSecretUpdate_Call) Return(err error) *MockService_OrgSecretUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockService_OrgSecretUpdate_Call) RunAndReturn(run func(n int64, secret *model.Secret) error) *MockService_OrgSecretUpdate_Call { _c.Call.Return(run) return _c } // SecretCreate provides a mock function for the type MockService func (_mock *MockService) SecretCreate(repo *model.Repo, secret *model.Secret) error { ret := _mock.Called(repo, secret) if len(ret) == 0 { panic("no return value specified for SecretCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Secret) error); ok { r0 = returnFunc(repo, secret) } else { r0 = ret.Error(0) } return r0 } // MockService_SecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretCreate' type MockService_SecretCreate_Call struct { *mock.Call } // SecretCreate is a helper method to define mock.On call // - repo *model.Repo // - secret *model.Secret func (_e *MockService_Expecter) SecretCreate(repo interface{}, secret interface{}) *MockService_SecretCreate_Call { return &MockService_SecretCreate_Call{Call: _e.mock.On("SecretCreate", repo, secret)} } func (_c *MockService_SecretCreate_Call) Run(run func(repo *model.Repo, secret *model.Secret)) *MockService_SecretCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.Secret if args[1] != nil { arg1 = args[1].(*model.Secret) } run( arg0, arg1, ) }) return _c } func (_c *MockService_SecretCreate_Call) Return(err error) *MockService_SecretCreate_Call { _c.Call.Return(err) return _c } func (_c *MockService_SecretCreate_Call) RunAndReturn(run func(repo *model.Repo, secret *model.Secret) error) *MockService_SecretCreate_Call { _c.Call.Return(run) return _c } // SecretDelete provides a mock function for the type MockService func (_mock *MockService) SecretDelete(repo *model.Repo, s string) error { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for SecretDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) error); ok { r0 = returnFunc(repo, s) } else { r0 = ret.Error(0) } return r0 } // MockService_SecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretDelete' type MockService_SecretDelete_Call struct { *mock.Call } // SecretDelete is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockService_Expecter) SecretDelete(repo interface{}, s interface{}) *MockService_SecretDelete_Call { return &MockService_SecretDelete_Call{Call: _e.mock.On("SecretDelete", repo, s)} } func (_c *MockService_SecretDelete_Call) Run(run func(repo *model.Repo, s string)) *MockService_SecretDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_SecretDelete_Call) Return(err error) *MockService_SecretDelete_Call { _c.Call.Return(err) return _c } func (_c *MockService_SecretDelete_Call) RunAndReturn(run func(repo *model.Repo, s string) error) *MockService_SecretDelete_Call { _c.Call.Return(run) return _c } // SecretFind provides a mock function for the type MockService func (_mock *MockService) SecretFind(repo *model.Repo, s string) (*model.Secret, error) { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for SecretFind") } var r0 *model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Secret, error)); ok { return returnFunc(repo, s) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Secret); ok { r0 = returnFunc(repo, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok { r1 = returnFunc(repo, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_SecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretFind' type MockService_SecretFind_Call struct { *mock.Call } // SecretFind is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockService_Expecter) SecretFind(repo interface{}, s interface{}) *MockService_SecretFind_Call { return &MockService_SecretFind_Call{Call: _e.mock.On("SecretFind", repo, s)} } func (_c *MockService_SecretFind_Call) Run(run func(repo *model.Repo, s string)) *MockService_SecretFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockService_SecretFind_Call) Return(secret *model.Secret, err error) *MockService_SecretFind_Call { _c.Call.Return(secret, err) return _c } func (_c *MockService_SecretFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Secret, error)) *MockService_SecretFind_Call { _c.Call.Return(run) return _c } // SecretList provides a mock function for the type MockService func (_mock *MockService) SecretList(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Secret, error) { ret := _mock.Called(repo, listOptions) if len(ret) == 0 { panic("no return value specified for SecretList") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Secret, error)); ok { return returnFunc(repo, listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Secret); ok { r0 = returnFunc(repo, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok { r1 = returnFunc(repo, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_SecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretList' type MockService_SecretList_Call struct { *mock.Call } // SecretList is a helper method to define mock.On call // - repo *model.Repo // - listOptions *model.ListOptions func (_e *MockService_Expecter) SecretList(repo interface{}, listOptions interface{}) *MockService_SecretList_Call { return &MockService_SecretList_Call{Call: _e.mock.On("SecretList", repo, listOptions)} } func (_c *MockService_SecretList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions)) *MockService_SecretList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockService_SecretList_Call) Return(secrets []*model.Secret, err error) *MockService_SecretList_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockService_SecretList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockService_SecretList_Call { _c.Call.Return(run) return _c } // SecretListPipeline provides a mock function for the type MockService func (_mock *MockService) SecretListPipeline(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) { ret := _mock.Called(context1, repo, pipeline, netrc) if len(ret) == 0 { panic("no return value specified for SecretListPipeline") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Secret, error)); ok { return returnFunc(context1, repo, pipeline, netrc) } if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) []*model.Secret); ok { r0 = returnFunc(context1, repo, pipeline, netrc) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) error); ok { r1 = returnFunc(context1, repo, pipeline, netrc) } else { r1 = ret.Error(1) } return r0, r1 } // MockService_SecretListPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretListPipeline' type MockService_SecretListPipeline_Call struct { *mock.Call } // SecretListPipeline is a helper method to define mock.On call // - context1 context.Context // - repo *model.Repo // - pipeline *model.Pipeline // - netrc *model.Netrc func (_e *MockService_Expecter) SecretListPipeline(context1 interface{}, repo interface{}, pipeline interface{}, netrc interface{}) *MockService_SecretListPipeline_Call { return &MockService_SecretListPipeline_Call{Call: _e.mock.On("SecretListPipeline", context1, repo, pipeline, netrc)} } func (_c *MockService_SecretListPipeline_Call) Run(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc)) *MockService_SecretListPipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 *model.Repo if args[1] != nil { arg1 = args[1].(*model.Repo) } var arg2 *model.Pipeline if args[2] != nil { arg2 = args[2].(*model.Pipeline) } var arg3 *model.Netrc if args[3] != nil { arg3 = args[3].(*model.Netrc) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockService_SecretListPipeline_Call) Return(secrets []*model.Secret, err error) *MockService_SecretListPipeline_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockService_SecretListPipeline_Call) RunAndReturn(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error)) *MockService_SecretListPipeline_Call { _c.Call.Return(run) return _c } // SecretUpdate provides a mock function for the type MockService func (_mock *MockService) SecretUpdate(repo *model.Repo, secret *model.Secret) error { ret := _mock.Called(repo, secret) if len(ret) == 0 { panic("no return value specified for SecretUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Secret) error); ok { r0 = returnFunc(repo, secret) } else { r0 = ret.Error(0) } return r0 } // MockService_SecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretUpdate' type MockService_SecretUpdate_Call struct { *mock.Call } // SecretUpdate is a helper method to define mock.On call // - repo *model.Repo // - secret *model.Secret func (_e *MockService_Expecter) SecretUpdate(repo interface{}, secret interface{}) *MockService_SecretUpdate_Call { return &MockService_SecretUpdate_Call{Call: _e.mock.On("SecretUpdate", repo, secret)} } func (_c *MockService_SecretUpdate_Call) Run(run func(repo *model.Repo, secret *model.Secret)) *MockService_SecretUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.Secret if args[1] != nil { arg1 = args[1].(*model.Secret) } run( arg0, arg1, ) }) return _c } func (_c *MockService_SecretUpdate_Call) Return(err error) *MockService_SecretUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockService_SecretUpdate_Call) RunAndReturn(run func(repo *model.Repo, secret *model.Secret) error) *MockService_SecretUpdate_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/services/secret/service.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package secret import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // Service defines a service for managing secrets. type Service interface { SecretListPipeline(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Secret, error) // Repository secrets SecretFind(*model.Repo, string) (*model.Secret, error) SecretList(*model.Repo, *model.ListOptions) ([]*model.Secret, error) SecretCreate(*model.Repo, *model.Secret) error SecretUpdate(*model.Repo, *model.Secret) error SecretDelete(*model.Repo, string) error // Organization secrets OrgSecretFind(int64, string) (*model.Secret, error) OrgSecretList(int64, *model.ListOptions) ([]*model.Secret, error) OrgSecretCreate(int64, *model.Secret) error OrgSecretUpdate(int64, *model.Secret) error OrgSecretDelete(int64, string) error // Global secrets GlobalSecretFind(string) (*model.Secret, error) GlobalSecretList(*model.ListOptions) ([]*model.Secret, error) GlobalSecretCreate(*model.Secret) error GlobalSecretUpdate(*model.Secret) error GlobalSecretDelete(string) error } ================================================ FILE: server/services/setup.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package services import ( "crypto" "crypto/ed25519" "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/services/config" "go.woodpecker-ci.org/woodpecker/v3/server/services/registry" "go.woodpecker-ci.org/woodpecker/v3/server/services/secret" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func setupRegistryService(store store.Store, dockerConfig, endpoint string, includeNetrc bool, client *utils.Client) registry.Service { var service registry.Service if dockerConfig != "" { service = registry.NewCombined( registry.NewDB(store), registry.NewFilesystem(dockerConfig), ) } else { service = registry.NewDB(store) } // Wrap with global HTTP extension if configured if endpoint != "" { service = registry.NewWithExtension(service, registry.NewHTTP(endpoint, client, includeNetrc)) } return service } func setupSecretService(store store.Store, endpoint string, client *utils.Client, includeNetrc bool) secret.Service { // TODO(1544): fix encrypted store // // encryption // encryptedSecretStore := encryptedStore.NewSecretStore(v) // err := encryption.Encryption(c, v).WithClient(encryptedSecretStore).Build() // if err != nil { // log.Fatal().Err(err).Msg("could not create encryption service") // } if endpoint != "" { return secret.NewCombined(secret.NewDB(store), secret.NewHTTP(endpoint, client, includeNetrc)) } return secret.NewDB(store) } func setupConfigService(c *cli.Command, client *utils.Client) (config.Service, error) { timeout := c.Duration("forge-timeout") retries := c.Uint("forge-retry") if retries == 0 { return nil, fmt.Errorf("WOODPECKER_FORGE_RETRY can not be 0") } configFetcher := config.NewForge(timeout, retries) if endpoint := c.String("config-extension-endpoint"); endpoint != "" { httpFetcher := config.NewHTTP(endpoint, client, c.Bool("config-extension-netrc")) if c.Bool("config-extension-exclusive") { return httpFetcher, nil } return config.NewCombined(configFetcher, httpFetcher), nil } return configFetcher, nil } // setupSignatureKeys generate or load key pair to sign webhooks requests (i.e. used for service extensions). func setupSignatureKeys(_store store.Store) (ed25519.PrivateKey, crypto.PublicKey, error) { privKeyID := "signature-private-key" privKey, err := _store.ServerConfigGet(privKeyID) if errors.Is(err, types.ErrRecordNotExist) { _, privKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, nil, fmt.Errorf("failed to generate private key: %w", err) } err = _store.ServerConfigSet(privKeyID, hex.EncodeToString(privKey)) if err != nil { return nil, nil, fmt.Errorf("failed to store private key: %w", err) } log.Debug().Msg("created private key") return privKey, privKey.Public(), nil } else if err != nil { return nil, nil, fmt.Errorf("failed to load private key: %w", err) } privKeyStr, err := hex.DecodeString(privKey) if err != nil { return nil, nil, fmt.Errorf("failed to decode private key: %w", err) } privateKey := ed25519.PrivateKey(privKeyStr) return privateKey, privateKey.Public(), nil } func setupForgeService(c *cli.Command, _store store.Store) error { _forge, err := _store.ForgeGet(1) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { return err } forgeExists := err == nil if _forge == nil { _forge = &model.Forge{ ID: 0, } } if _forge.AdditionalOptions == nil { _forge.AdditionalOptions = make(map[string]any) } _forge.OAuthClientID = strings.TrimSpace(c.String("forge-oauth-client")) _forge.OAuthClientSecret = strings.TrimSpace(c.String("forge-oauth-secret")) _forge.URL = c.String("forge-url") _forge.SkipVerify = c.Bool("forge-skip-verify") _forge.OAuthHost = c.String("forge-oauth-host") switch { case c.String("addon-forge") != "": _forge.Type = model.ForgeTypeAddon _forge.AdditionalOptions["executable"] = c.String("addon-forge") case c.Bool("github"): _forge.Type = model.ForgeTypeGithub _forge.AdditionalOptions["merge-ref"] = c.Bool("github-merge-ref") _forge.AdditionalOptions["public-only"] = c.Bool("github-public-only") if _forge.URL == "" { _forge.URL = "https://github.com" } case c.Bool("gitlab"): _forge.Type = model.ForgeTypeGitlab if _forge.URL == "" { _forge.URL = "https://gitlab.com" } case c.Bool("gitea"): _forge.Type = model.ForgeTypeGitea if _forge.URL == "" { _forge.URL = "https://try.gitea.com" } case c.Bool("forgejo"): _forge.Type = model.ForgeTypeForgejo // TODO enable oauth URL with generic config option if _forge.URL == "" { _forge.URL = "https://next.forgejo.org" } case c.Bool("bitbucket"): _forge.Type = model.ForgeTypeBitbucket case c.Bool("bitbucket-dc"): _forge.Type = model.ForgeTypeBitbucketDatacenter _forge.AdditionalOptions["git-username"] = c.String("bitbucket-dc-git-username") _forge.AdditionalOptions["git-password"] = c.String("bitbucket-dc-git-password") _forge.AdditionalOptions["oauth-enable-project-admin-scope"] = c.Bool("bitbucket-dc-oauth-enable-oauth2-scope-project-admin") default: return errors.New("forge not configured") } if forgeExists { err := _store.ForgeUpdate(_forge) if err != nil { return err } } else { err := _store.ForgeCreate(_forge) if err != nil { return err } } return nil } ================================================ FILE: server/services/utils/hostmatcher/hostmatcher.go ================================================ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT. // cSpell:words hostmatcher package hostmatcher import ( "net" "path/filepath" "strings" ) // HostMatchList is used to check if a host or IP is in a list. type HostMatchList struct { SettingKeyHint string SettingValue string // builtins networks builtins []string // patterns for host names (with wildcard support) patterns []string // ipNets is the CIDR network list ipNets []*net.IPNet } // MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched. const MatchBuiltinExternal = "external" // MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet. const MatchBuiltinPrivate = "private" // MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included. const MatchBuiltinLoopback = "loopback" func isBuiltin(s string) bool { return s == MatchBuiltinExternal || s == MatchBuiltinPrivate || s == MatchBuiltinLoopback } // ParseHostMatchList parses the host list HostMatchList. func ParseHostMatchList(settingKeyHint, hostList string) *HostMatchList { hl := &HostMatchList{SettingKeyHint: settingKeyHint, SettingValue: hostList} for _, s := range strings.Split(hostList, ",") { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { continue } _, ipNet, err := net.ParseCIDR(s) switch { case err == nil: hl.ipNets = append(hl.ipNets, ipNet) case isBuiltin(s): hl.builtins = append(hl.builtins, s) default: hl.patterns = append(hl.patterns, s) } } return hl } // ParseSimpleMatchList parse a simple match-list (no built-in networks, no CIDR support, only wildcard pattern match). func ParseSimpleMatchList(settingKeyHint, matchList string) *HostMatchList { hl := &HostMatchList{ SettingKeyHint: settingKeyHint, SettingValue: matchList, } for _, s := range strings.Split(matchList, ",") { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { continue } // we keep the same result as old `match-list`, so no builtin/CIDR support here, we only match wildcard patterns hl.patterns = append(hl.patterns, s) } return hl } // AppendBuiltin appends more builtins to match. func (hl *HostMatchList) AppendBuiltin(builtin string) { hl.builtins = append(hl.builtins, builtin) } // AppendPattern appends more pattern to match. func (hl *HostMatchList) AppendPattern(pattern string) { hl.patterns = append(hl.patterns, pattern) } // IsEmpty checks if the checklist is empty. func (hl *HostMatchList) IsEmpty() bool { return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0) } func (hl *HostMatchList) checkPattern(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) for _, pattern := range hl.patterns { if matched, _ := filepath.Match(pattern, host); matched { return true } } return false } func (hl *HostMatchList) checkIP(ip net.IP) bool { for _, pattern := range hl.patterns { if pattern == "*" { return true } } for _, builtin := range hl.builtins { switch builtin { case MatchBuiltinExternal: if ip.IsGlobalUnicast() && !ip.IsPrivate() { return true } case MatchBuiltinPrivate: if ip.IsPrivate() { return true } case MatchBuiltinLoopback: if ip.IsLoopback() { return true } } } for _, ipNet := range hl.ipNets { if ipNet.Contains(ip) { return true } } return false } // MatchHostName checks if the host matches an allow/deny(block) list. func (hl *HostMatchList) MatchHostName(host string) bool { if hl == nil { return false } hostname, _, err := net.SplitHostPort(host) if err != nil { hostname = host } if hl.checkPattern(hostname) { return true } if ip := net.ParseIP(hostname); ip != nil { return hl.checkIP(ip) } return false } // MatchIPAddr checks if the IP matches an allow/deny(block) list, it's safe to pass `nil` to `ip`. func (hl *HostMatchList) MatchIPAddr(ip net.IP) bool { if hl == nil { return false } host := ip.String() // nil-safe, we will get "" if ip is nil return hl.checkPattern(host) || hl.checkIP(ip) } // MatchHostOrIP checks if the host or IP matches an allow/deny(block) list. func (hl *HostMatchList) MatchHostOrIP(host string, ip net.IP) bool { return hl.MatchHostName(host) || hl.MatchIPAddr(ip) } ================================================ FILE: server/services/utils/hostmatcher/hostmatcher_test.go ================================================ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT. package hostmatcher import ( "net" "testing" "github.com/stretchr/testify/assert" ) func TestHostOrIPMatchesList(t *testing.T) { type tc struct { host string ip net.IP expected bool } // for IPv6: "::1" is loopback, "fd00::/8" is private hl := ParseHostMatchList("", "private, External, *.myDomain.com, 169.254.1.0/24") test := func(cases []tc) { for _, c := range cases { assert.Equalf(t, c.expected, hl.MatchHostOrIP(c.host, c.ip), "case domain=%s, ip=%v, expected=%v", c.host, c.ip, c.expected) } } cases := []tc{ {"", net.IPv4zero, false}, {"", net.IPv6zero, false}, {"", net.ParseIP("127.0.0.1"), false}, {"127.0.0.1", nil, false}, {"", net.ParseIP("::1"), false}, {"", net.ParseIP("10.0.1.1"), true}, {"10.0.1.1", nil, true}, {"10.0.1.1:8080", nil, true}, {"", net.ParseIP("192.168.1.1"), true}, {"192.168.1.1", nil, true}, {"", net.ParseIP("fd00::1"), true}, {"fd00::1", nil, true}, {"", net.ParseIP("8.8.8.8"), true}, {"", net.ParseIP("1001::1"), true}, {"mydomain.com", net.IPv4zero, false}, {"sub.mydomain.com", net.IPv4zero, true}, {"sub.mydomain.com:8080", net.IPv4zero, true}, {"", net.ParseIP("169.254.1.1"), true}, {"169.254.1.1", nil, true}, {"", net.ParseIP("169.254.2.2"), false}, {"169.254.2.2", nil, false}, } test(cases) hl = ParseHostMatchList("", "loopback") cases = []tc{ {"", net.IPv4zero, false}, {"", net.ParseIP("127.0.0.1"), true}, {"", net.ParseIP("10.0.1.1"), false}, {"", net.ParseIP("192.168.1.1"), false}, {"", net.ParseIP("8.8.8.8"), false}, {"", net.ParseIP("::1"), true}, {"", net.ParseIP("fd00::1"), false}, {"", net.ParseIP("1000::1"), false}, {"mydomain.com", net.IPv4zero, false}, } test(cases) hl = ParseHostMatchList("", "private") cases = []tc{ {"", net.IPv4zero, false}, {"", net.ParseIP("127.0.0.1"), false}, {"", net.ParseIP("10.0.1.1"), true}, {"", net.ParseIP("192.168.1.1"), true}, {"", net.ParseIP("8.8.8.8"), false}, {"", net.ParseIP("::1"), false}, {"", net.ParseIP("fd00::1"), true}, {"", net.ParseIP("1000::1"), false}, {"mydomain.com", net.IPv4zero, false}, } test(cases) hl = ParseHostMatchList("", "external") cases = []tc{ {"", net.IPv4zero, false}, {"", net.ParseIP("127.0.0.1"), false}, {"", net.ParseIP("10.0.1.1"), false}, {"", net.ParseIP("192.168.1.1"), false}, {"", net.ParseIP("8.8.8.8"), true}, {"", net.ParseIP("::1"), false}, {"", net.ParseIP("fd00::1"), false}, {"", net.ParseIP("1000::1"), true}, {"mydomain.com", net.IPv4zero, false}, } test(cases) hl = ParseHostMatchList("", "*") cases = []tc{ {"", net.IPv4zero, true}, {"", net.ParseIP("127.0.0.1"), true}, {"", net.ParseIP("10.0.1.1"), true}, {"", net.ParseIP("192.168.1.1"), true}, {"", net.ParseIP("8.8.8.8"), true}, {"", net.ParseIP("::1"), true}, {"", net.ParseIP("fd00::1"), true}, {"", net.ParseIP("1000::1"), true}, {"mydomain.com", net.IPv4zero, true}, } test(cases) // built-in network names can be escaped (warping the first char with `[]`) to be used as a real host name // this mechanism is reversed for internal usage only (maybe for some rare cases), it's not supposed to be used by end users // a real user should never use loopback/private/external as their host names hl = ParseHostMatchList("", "loopback, [p]rivate") cases = []tc{ {"loopback", nil, false}, {"", net.ParseIP("127.0.0.1"), true}, {"private", nil, true}, {"", net.ParseIP("192.168.1.1"), false}, } test(cases) hl = ParseSimpleMatchList("", "loopback, *.domain.com") cases = []tc{ {"loopback", nil, true}, {"", net.ParseIP("127.0.0.1"), false}, {"sub.domain.com", nil, true}, {"other.com", nil, false}, {"", net.ParseIP("1.1.1.1"), false}, } test(cases) hl = ParseSimpleMatchList("", "external") cases = []tc{ {"", net.ParseIP("192.168.1.1"), false}, {"", net.ParseIP("1.1.1.1"), false}, {"external", nil, true}, } test(cases) hl = ParseSimpleMatchList("", "") cases = []tc{ {"", net.ParseIP("192.168.1.1"), false}, {"", net.ParseIP("1.1.1.1"), false}, {"external", nil, false}, } test(cases) } ================================================ FILE: server/services/utils/hostmatcher/http.go ================================================ // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT. // cSpell:words hostmatcher package hostmatcher import ( "context" "fmt" "net" "net/url" "syscall" "time" ) // NewDialContext returns a DialContext for Transport, the DialContext will do allow/block list check. func NewDialContext(usage string, allowList *HostMatchList) func(ctx context.Context, network, addr string) (net.Conn, error) { return NewDialContextWithProxy(usage, allowList, nil) } func NewDialContextWithProxy(usage string, allowList *HostMatchList, proxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) { // How Go HTTP Client works with redirection: // transport.RoundTrip URL=http://domain.com, Host=domain.com // transport.DialContext addrOrHost=domain.com:80 // dialer.Control tcp4:11.22.33.44:80 // transport.RoundTrip URL=http://www.domain.com/, Host=(empty here, in the direction, HTTP client doesn't fill the Host field) // transport.DialContext addrOrHost=domain.com:80 // dialer.Control tcp4:11.22.33.44:80 return func(ctx context.Context, network, addrOrHost string) (net.Conn, error) { // default values are from http.DefaultTransport const dialTimeout = 30 * time.Second const dialKeepAlive = 30 * time.Second dialer := net.Dialer{ Timeout: dialTimeout, KeepAlive: dialKeepAlive, Control: func(network, ipAddr string, _ syscall.RawConn) error { host, port, err := net.SplitHostPort(addrOrHost) if err != nil { return err } if proxy != nil { // Always allow the host of the proxy, but only on the specified port. if host == proxy.Hostname() && port == proxy.Port() { return nil } } // in Control func, the addr was already resolved to IP:PORT format, there is no cost to do ResolveTCPAddr here tcpAddr, err := net.ResolveTCPAddr(network, ipAddr) if err != nil { return fmt.Errorf("%s can only call HTTP servers via TCP, deny '%s(%s:%s)', err=%w", usage, host, network, ipAddr, err) } // if we have an allow-list, check the allow-list first if !allowList.IsEmpty() { if !allowList.MatchHostOrIP(host, tcpAddr.IP) { return fmt.Errorf("%s can only call allowed HTTP servers (check your %s setting), deny '%s(%s)'", usage, allowList.SettingKeyHint, host, ipAddr) } } return nil }, } return dialer.DialContext(ctx, network, addrOrHost) } } ================================================ FILE: server/services/utils/http.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "bytes" "context" "crypto" "crypto/ed25519" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "github.com/cenkalti/backoff/v5" "github.com/rs/zerolog/log" "github.com/yaronf/httpsign" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils/hostmatcher" "go.woodpecker-ci.org/woodpecker/v3/shared/httputil" ) type Client struct { *httpsign.Client } func getHTTPClient(privateKey crypto.PrivateKey, allowedHostListValue string) (*httpsign.Client, error) { timeout := 10 * time.Second //nolint:mnd if allowedHostListValue == "" { allowedHostListValue = hostmatcher.MatchBuiltinExternal } allowedHostMatcher := hostmatcher.ParseHostMatchList("WOODPECKER_EXTENSIONS_ALLOWED_HOSTS", allowedHostListValue) pubKeyID := "woodpecker-ci-extensions" ed25519Key, ok := privateKey.(ed25519.PrivateKey) if !ok { return nil, fmt.Errorf("invalid private key type") } signer, err := httpsign.NewEd25519Signer(ed25519Key, httpsign.NewSignConfig(), httpsign.Headers("@request-target", "content-digest")) // The Content-Digest header will be auto-generated if err != nil { return nil, err } // Create base transport with custom User-Agent baseTransport := httputil.NewUserAgentRoundTripper( &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, DialContext: hostmatcher.NewDialContext("extensions", allowedHostMatcher), }, "server-extensions", ) client := http.Client{ Timeout: timeout, Transport: baseTransport, } config := httpsign.NewClientConfig().SetSignatureName(pubKeyID).SetSigner(signer) return httpsign.NewClient(client, config), nil } func NewHTTPClient(privateKey crypto.PrivateKey, allowedHostList string) (*Client, error) { client, err := getHTTPClient(privateKey, allowedHostList) if err != nil { return nil, err } return &Client{ Client: client, }, nil } // Send makes an http request with retry logic. func (e *Client) Send(ctx context.Context, method, path string, in, out any) (int, error) { // Maximum number of retries const maxRetries = 3 log.Debug().Msgf("HTTP request: %s %s, retries enabled (max: %d)", method, path, maxRetries) // Prepare request body bytes for possible retries var bodyBytes []byte if in != nil { buf := new(bytes.Buffer) if err := json.NewEncoder(buf).Encode(in); err != nil { return 0, err } bodyBytes = buf.Bytes() } // Parse URI once uri, err := url.Parse(path) if err != nil { return 0, err } // Create backoff configuration exponentialBackoff := backoff.NewExponentialBackOff() // Execute with backoff retry return backoff.Retry(ctx, func() (int, error) { // Check if context is already canceled if ctx.Err() != nil { return 0, ctx.Err() } // Create request body for this attempt var body io.Reader if len(bodyBytes) > 0 { body = bytes.NewReader(bodyBytes) } // Create new request for each attempt req, err := http.NewRequestWithContext(ctx, method, uri.String(), body) if err != nil { return 0, httputil.EnhanceHTTPError(err, method, path) } if in != nil { req.Header.Set("Content-Type", "application/json") } // Send request resp, err := e.Do(req) if err != nil { // Check if this is a retryable error if !isRetryableError(err) { log.Error().Err(err).Msgf("HTTP request failed (not retryable): %s %s", method, path) return 0, backoff.Permanent(err) } return 0, err } statusCode := resp.StatusCode // Read body immediately to ensure proper resource cleanup for retries respBody, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { // Check if this is a retryable error if !isRetryableError(readErr) { log.Error().Err(readErr).Msgf("HTTP response read failed (not retryable): %s %s", method, path) return statusCode, backoff.Permanent(readErr) } return statusCode, readErr } // Check if status code is retryable if isRetryableStatusCode(statusCode) { return statusCode, fmt.Errorf("response: %d", statusCode) } // If status code is client error (4xx), don't retry if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError { log.Debug().Int("status", statusCode).Msgf("HTTP request returned client error (not retryable): %s %s", method, path) return statusCode, backoff.Permanent(fmt.Errorf("response: %s", string(respBody))) } // If status code is OK (2xx), parse and return response if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices { if out != nil { err = json.NewDecoder(bytes.NewReader(respBody)).Decode(out) // Check for EOF error during response body parsing if err != nil && (errors.Is(err, io.EOF) || strings.Contains(err.Error(), "unexpected EOF")) { return statusCode, err } if err != nil { log.Error().Err(err).Msgf("HTTP response parsing failed (not retryable): %s %s", method, path) return statusCode, backoff.Permanent(err) } } log.Debug().Int("status", statusCode).Msgf("HTTP request succeeded: %s %s", method, path) return statusCode, nil } // For any other status code, don't retry log.Error().Int("status", statusCode).Msgf("HTTP request returned unexpected status code (not retryable): %s %s", method, path) return statusCode, backoff.Permanent(fmt.Errorf("response: %s", string(respBody))) }, backoff.WithBackOff(exponentialBackoff), backoff.WithMaxTries(maxRetries), backoff.WithNotify(func(err error, delay time.Duration) { // Log retry attempts log.Debug().Err(err).Msgf("HTTP request failed, retrying in %v: %s %s", delay, method, path) }), ) } // isRetryableError checks if an error is transient and suitable for retry. func isRetryableError(err error) bool { // Check for network-related errors var netErr net.Error if errors.As(err, &netErr) { // Retry on timeout errors if netErr.Timeout() { return true } } // Check for specific error types switch { case errors.Is(err, net.ErrClosed), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF): return true } // Check for error strings that indicate retryable conditions errStr := err.Error() return strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "connection reset by peer") || strings.Contains(errStr, "no such host") || strings.Contains(errStr, "TLS handshake timeout") } // isRetryableStatusCode checks if an HTTP status code is suitable for retry. func isRetryableStatusCode(statusCode int) bool { // Retry on server errors (5xx) return statusCode >= http.StatusInternalServerError && statusCode < http.StatusNetworkAuthenticationRequired } ================================================ FILE: server/services/utils/http_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils_test import ( "bytes" "crypto/ed25519" "crypto/rand" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yaronf/httpsign" "go.woodpecker-ci.org/woodpecker/v3/server/services/utils" ) func TestSignClient(t *testing.T) { pubKeyID := "woodpecker-ci-extensions" pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) body := []byte("{\"foo\":\"bar\"}") verifyHandler := func(w http.ResponseWriter, r *http.Request) { verifier, err := httpsign.NewEd25519Verifier(pubEd25519Key, httpsign.NewVerifyConfig(), httpsign.Headers("@request-target", "content-digest")) // The Content-Digest header will be auto-generated assert.NoError(t, err) err = httpsign.VerifyRequest(pubKeyID, *verifier, r) assert.NoError(t, err) w.WriteHeader(http.StatusOK) } server := httptest.NewServer(http.HandlerFunc(verifyHandler)) req, err := http.NewRequest("GET", server.URL+"/", bytes.NewBuffer(body)) require.NoError(t, err) req.Header.Set("Date", time.Now().Format(time.RFC3339)) req.Header.Set("Content-Type", "application/json") client, err := utils.NewHTTPClient(privEd25519Key, "loopback") require.NoError(t, err) rr, err := client.Do(req) assert.NoError(t, err) defer rr.Body.Close() assert.Equal(t, http.StatusOK, rr.StatusCode) } func TestRetry(t *testing.T) { _, privEd25519Key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) numRetry := 0 body := []byte("{\"foo\":\"bar\"}") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { numRetry++ if numRetry >= 6 { w.WriteHeader(http.StatusNoContent) } else { w.WriteHeader(http.StatusInternalServerError) } })) client, err := utils.NewHTTPClient(privEd25519Key, "loopback") require.NoError(t, err) // first time: retry fails all the times _, err = client.Send(t.Context(), http.MethodGet, server.URL+"/", bytes.NewBuffer(body), nil) assert.Error(t, err) assert.Equal(t, 3, numRetry) // second time: retry succeeds after two failed times rr, err := client.Send(t.Context(), http.MethodGet, server.URL+"/", bytes.NewBuffer(body), nil) assert.NoError(t, err) assert.Equal(t, http.StatusNoContent, rr) assert.Equal(t, 6, numRetry) } ================================================ FILE: server/store/common.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import "time" type XORM struct { Log bool ShowSQL bool MaxIdleConns int MaxOpenConns int ConnMaxLifetime time.Duration } // Opts are options for a new database connection. type Opts struct { Driver string Config string XORM XORM } ================================================ FILE: server/store/context.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import ( "context" "github.com/gin-gonic/gin" ) const key = "store" // FromContext returns the Store associated with this context. func FromContext(c context.Context) Store { store, _ := c.Value(key).(Store) return store } // TryFromContext try to return the Store associated with this context. func TryFromContext(c context.Context) (Store, bool) { store, ok := c.Value(key).(Store) return store, ok } // ToContext adds the Store to this context. func ToContext(c *gin.Context, store Store) { c.Set(key, store) } func InjectToContext(ctx context.Context, store Store) context.Context { return context.WithValue(ctx, key, store) //nolint:staticcheck } ================================================ FILE: server/store/datastore/agent.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "errors" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var ErrNoTokenProvided = errors.New("please provide a token") func (s storage) AgentList(p *model.ListOptions) (agents []*model.Agent, _ error) { return agents, s.paginate(p).OrderBy("id").Find(&agents) } func (s storage) AgentFind(id int64) (*model.Agent, error) { agent := new(model.Agent) return agent, wrapGet(s.engine.ID(id).Get(agent)) } func (s storage) AgentFindByToken(token string) (*model.Agent, error) { // Searching with an empty token would result in an empty where clause and therefore returning first item if token == "" { return nil, ErrNoTokenProvided } agent := new(model.Agent) return agent, wrapGet(s.engine.Where("token = ?", token).Get(agent)) } func (s storage) AgentCreate(agent *model.Agent) error { // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(agent)) } func (s storage) AgentUpdate(agent *model.Agent) error { _, err := s.engine.ID(agent.ID).AllCols().Update(agent) return err } func (s storage) AgentDelete(agent *model.Agent) error { return wrapDelete(s.engine.ID(agent.ID).Delete(new(model.Agent))) } func (s storage) AgentListForOrg(orgID int64, p *model.ListOptions) (agents []*model.Agent, _ error) { return agents, s.paginate(p).Where("org_id = ?", orgID).OrderBy("id").Find(&agents) } ================================================ FILE: server/store/datastore/agent_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestAgentFindByToken(t *testing.T) { store, closer := newTestStore(t, new(model.Agent)) defer closer() agent := &model.Agent{ ID: int64(1), Name: "test", Token: "secret-token", } err := store.AgentCreate(agent) assert.NoError(t, err) _agent, err := store.AgentFindByToken(agent.Token) assert.NoError(t, err) assert.EqualValues(t, 1, _agent.ID) _agent, err = store.AgentFindByToken("") assert.ErrorIs(t, err, ErrNoTokenProvided) assert.Nil(t, _agent) } func TestAgentFindByID(t *testing.T) { store, closer := newTestStore(t, new(model.Agent)) defer closer() agent := &model.Agent{ ID: int64(1), Name: "test", Token: "secret-token", } err := store.AgentCreate(agent) assert.NoError(t, err) _agent, err := store.AgentFind(agent.ID) assert.NoError(t, err) assert.Equal(t, "secret-token", _agent.Token) } func TestAgentList(t *testing.T) { store, closer := newTestStore(t, new(model.Agent)) defer closer() agent1 := &model.Agent{ ID: int64(1), Name: "test-1", } agent2 := &model.Agent{ ID: int64(2), Name: "test-2", } err := store.AgentCreate(agent1) assert.NoError(t, err) err = store.AgentCreate(agent2) assert.NoError(t, err) agents, err := store.AgentList(&model.ListOptions{All: true}) assert.NoError(t, err) assert.Equal(t, 2, len(agents)) agents, err = store.AgentList(&model.ListOptions{Page: 1, PerPage: 1}) assert.NoError(t, err) assert.Equal(t, 1, len(agents)) } func TestAgentUpdate(t *testing.T) { store, closer := newTestStore(t, new(model.Agent)) defer closer() agent := &model.Agent{ ID: int64(1), Name: "test", Token: "secret-token", } err := store.AgentCreate(agent) assert.NoError(t, err) agent.Backend = "local" agent.Capacity = 2 agent.Version = "next-abcdef" err = store.AgentUpdate(agent) assert.NoError(t, err) } func TestAgentListForOrg(t *testing.T) { store, closer := newTestStore(t, new(model.Agent)) defer closer() agent1 := &model.Agent{ ID: int64(1), Name: "test-1", OrgID: int64(100), } agent2 := &model.Agent{ ID: int64(2), Name: "test-2", OrgID: int64(100), } agent3 := &model.Agent{ ID: int64(3), Name: "test-3", OrgID: int64(200), } assert.NoError(t, store.AgentCreate(agent1)) assert.NoError(t, store.AgentCreate(agent2)) assert.NoError(t, store.AgentCreate(agent3)) agents, err := store.AgentListForOrg(100, &model.ListOptions{All: true}) assert.NoError(t, err) assert.Equal(t, 2, len(agents)) assert.Equal(t, "test-1", agents[0].Name) assert.Equal(t, "test-2", agents[1].Name) agents, err = store.AgentListForOrg(200, &model.ListOptions{All: true}) assert.NoError(t, err) assert.Equal(t, 1, len(agents)) assert.Equal(t, "test-3", agents[0].Name) agents, err = store.AgentListForOrg(100, &model.ListOptions{Page: 1, PerPage: 1}) assert.NoError(t, err) assert.Equal(t, 1, len(agents)) assert.Equal(t, "test-1", agents[0].Name) } ================================================ FILE: server/store/datastore/config.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "crypto/sha256" "errors" "fmt" "xorm.io/builder" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (s storage) ConfigsForPipeline(pipelineID int64) ([]*model.Config, error) { configs := make([]*model.Config, 0, perPage) return configs, s.engine. Table("configs"). Join("LEFT", "pipeline_configs", "configs.id = pipeline_configs.config_id"). Where("pipeline_configs.pipeline_id = ?", pipelineID). Find(&configs) } func (s storage) configFindIdentical(sess *xorm.Session, repoID int64, hash, name string) (*model.Config, error) { conf := new(model.Config) if err := wrapGet(sess.Where( builder.Eq{"repo_id": repoID, "hash": hash, "name": name}, ).Get(conf)); err != nil { return nil, err } return conf, nil } func (s storage) ConfigPersist(conf *model.Config) (*model.Config, error) { conf.Hash = fmt.Sprintf("%x", sha256.Sum256(conf.Data)) sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return nil, err } existingConfig, err := s.configFindIdentical(sess, conf.RepoID, conf.Hash, conf.Name) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { return nil, err } if existingConfig != nil { return existingConfig, nil } if err := s.configCreate(sess, conf); err != nil { return nil, err } return conf, sess.Commit() } func (s storage) configCreate(sess *xorm.Session, config *model.Config) error { // should never happen but just in case if config.Name == "" { return fmt.Errorf("insert config to store failed: 'Name' has to be set") } if config.Hash == "" { return fmt.Errorf("insert config to store failed: 'Hash' has to be set") } // only Insert set auto created ID back to object return wrapInsert(sess.Insert(config)) } func (s storage) PipelineConfigCreate(config *model.PipelineConfig) error { // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(config)) } ================================================ FILE: server/store/datastore/config_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var ( data = []byte("pipeline: [ { image: golang, commands: [ go build, go test ] } ]") hash = "8d8647c9aa90d893bfb79dddbe901f03e258588121e5202632f8ae5738590b26" name = "test" ) func TestConfig(t *testing.T) { store, closer := newTestStore(t, new(model.Config), new(model.PipelineConfig), new(model.Pipeline), new(model.Repo)) defer closer() repo := &model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } assert.NoError(t, store.CreateRepo(repo)) config := &model.Config{ RepoID: repo.ID, Data: data, Hash: hash, Name: name, } _, err := store.ConfigPersist(config) assert.NoError(t, err) pipeline := &model.Pipeline{ RepoID: repo.ID, Status: model.StatusRunning, Commit: "85f8c029b902ed9400bc600bac301a0aadb144ac", } assert.NoError(t, store.CreatePipeline(pipeline)) assert.NoError(t, store.PipelineConfigCreate( &model.PipelineConfig{ ConfigID: config.ID, PipelineID: pipeline.ID, }, )) foundConfig, err := store.configFindIdentical(store.engine.NewSession(), repo.ID, hash, name) assert.NoError(t, err) assert.EqualValues(t, config, foundConfig) loaded, err := store.ConfigsForPipeline(pipeline.ID) assert.NoError(t, err) assert.Equal(t, config.ID, loaded[0].ID) } func TestConfigPersist(t *testing.T) { store, closer := newTestStore(t, new(model.Config)) defer closer() conf1 := &model.Config{ RepoID: 2, Data: data, Hash: hash, Name: name, } conf2 := &model.Config{ RepoID: 2, Data: []byte("steps: [ { image: golang, commands: [ go generate ] } ]"), Name: "generate", } conf1, err := store.ConfigPersist(conf1) assert.NoError(t, err) assert.EqualValues(t, hash, conf1.Hash) conf1secondInsert, err := store.ConfigPersist(conf1) assert.NoError(t, err) assert.EqualValues(t, conf1, conf1secondInsert) count, err := store.engine.Count(new(model.Config)) assert.NoError(t, err) assert.EqualValues(t, 1, count) newConf2, err := store.ConfigPersist(conf2) assert.NoError(t, err) assert.EqualValues(t, "66f28f8d487a48aacf29d9feea13b0ab5dbb5025296b77a6addde93efcc4d82b", newConf2.Hash) count, err = store.engine.Count(new(model.Config)) assert.NoError(t, err) assert.EqualValues(t, 2, count) // test for https://github.com/woodpecker-ci/woodpecker/issues/3093 _, err = store.ConfigPersist(&model.Config{ RepoID: 2, Data: data, Hash: hash, Name: "some other", }) assert.NoError(t, err) count, err = store.engine.Count(new(model.Config)) assert.NoError(t, err) assert.EqualValues(t, 3, count) } ================================================ FILE: server/store/datastore/cron.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "errors" "fmt" "xorm.io/builder" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (s storage) CronCreate(cron *model.Cron) error { if err := cron.Validate(); err != nil { return err } err := wrapInsert(s.engine.Insert(cron)) if errors.Is(err, types.ErrInsertDuplicateDetected) { return fmt.Errorf("create cron failed, duplicate detected: %w", err) } return err } func (s storage) CronFind(repo *model.Repo, id int64) (*model.Cron, error) { cron := new(model.Cron) return cron, wrapGet(s.engine.ID(id).Where("repo_id = ?", repo.ID).Get(cron)) } func (s storage) CronList(repo *model.Repo, p *model.ListOptions) ([]*model.Cron, error) { var crons []*model.Cron return crons, s.paginate(p).Where("repo_id = ?", repo.ID).OrderBy("name").Find(&crons) } func (s storage) CronUpdate(_ *model.Repo, cron *model.Cron) error { _, err := s.engine.ID(cron.ID).AllCols().Update(cron) return err } func (s storage) CronDelete(repo *model.Repo, id int64) error { return wrapDelete(s.engine.ID(id).Where("repo_id = ?", repo.ID).Delete(new(model.Cron))) } // CronListNextExecute returns limited number of jobs with NextExec being less or equal to the provided unix timestamp. func (s storage) CronListNextExecute(nextExec, limit int64) ([]*model.Cron, error) { crons := make([]*model.Cron, 0, limit) return crons, s.engine.Join("INNER", "repos", "repos.id = crons.repo_id").Where(builder.Lte{"next_exec": nextExec}).And(builder.Eq{"repos.active": true, "enabled": true}).Limit(int(limit)).Find(&crons) } // CronGetLock try to get a lock by updating NextExec. func (s storage) CronGetLock(cron *model.Cron, newNextExec int64) (bool, error) { cols, err := s.engine.ID(cron.ID).Where(builder.Eq{"next_exec": cron.NextExec}). Cols("next_exec").Update(&model.Cron{NextExec: newNextExec}) gotLock := cols != 0 if err == nil && gotLock { cron.NextExec = newNextExec } return gotLock, err } ================================================ FILE: server/store/datastore/cron_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "time" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestCronCreate(t *testing.T) { store, closer := newTestStore(t, new(model.Cron)) defer closer() repo := &model.Repo{ID: 1, Name: "repo"} cron1 := &model.Cron{RepoID: repo.ID, CreatorID: 1, Name: "sync", NextExec: 10000, Schedule: "@every 1h"} assert.NoError(t, store.CronCreate(cron1)) assert.NotEqualValues(t, 0, cron1.ID) // cannot insert cron job with same repoID and title assert.ErrorIs(t, store.CronCreate(cron1), types.ErrInsertDuplicateDetected) oldID := cron1.ID assert.NoError(t, store.CronDelete(repo, oldID)) cron1.ID = 0 assert.NoError(t, store.CronCreate(cron1)) assert.NotEqual(t, oldID, cron1.ID) } func TestCronListNextExecute(t *testing.T) { store, closer := newTestStore(t, new(model.Cron), new(model.Repo)) defer closer() repo1 := &model.Repo{Name: "aaaa", Owner: "a", FullName: "a/aaaa", ForgeRemoteID: "1", IsActive: true} repo2 := &model.Repo{Name: "bbbb", Owner: "a", FullName: "a/bbbb", ForgeRemoteID: "2", IsActive: false} assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) jobs, err := store.CronListNextExecute(0, 10) assert.NoError(t, err) assert.Len(t, jobs, 0) now := time.Now().Unix() assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "some", RepoID: repo1.ID, NextExec: now, Enabled: true})) assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "aaaa", RepoID: repo1.ID, NextExec: now, Enabled: true})) assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "bbbb", RepoID: repo1.ID, NextExec: now, Enabled: true})) assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "none", RepoID: repo1.ID, NextExec: now + 1000, Enabled: true})) assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "test", RepoID: repo1.ID, NextExec: now + 2000, Enabled: true})) assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "disabled-repo", RepoID: repo2.ID, NextExec: now, Enabled: true})) assert.NoError(t, store.CronCreate(&model.Cron{Schedule: "@every 1h", Name: "disabled-cron", RepoID: repo1.ID, NextExec: now, Enabled: false})) jobs, err = store.CronListNextExecute(now, 10) assert.NoError(t, err) assert.Len(t, jobs, 3) jobs, err = store.CronListNextExecute(now+1500, 10) assert.NoError(t, err) assert.Len(t, jobs, 4) } func TestCronGetLock(t *testing.T) { store, closer := newTestStore(t, new(model.Cron)) defer closer() nonExistingJob := &model.Cron{ID: 1000, Name: "locales", NextExec: 10000} gotLock, err := store.CronGetLock(nonExistingJob, time.Now().Unix()+100) assert.NoError(t, err) assert.False(t, gotLock) cron1 := &model.Cron{RepoID: 1, Name: "some-title", NextExec: 10000, Schedule: "@every 1h"} assert.NoError(t, store.CronCreate(cron1)) oldJob := *cron1 gotLock, err = store.CronGetLock(cron1, cron1.NextExec+1000) assert.NoError(t, err) assert.True(t, gotLock) assert.NotEqualValues(t, oldJob.NextExec, cron1.NextExec) gotLock, err = store.CronGetLock(&oldJob, oldJob.NextExec+1000) assert.NoError(t, err) assert.False(t, gotLock) assert.EqualValues(t, oldJob.NextExec, oldJob.NextExec) } ================================================ FILE: server/store/datastore/engine.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "context" "github.com/rs/zerolog" "xorm.io/xorm" xlog "xorm.io/xorm/log" "go.woodpecker-ci.org/woodpecker/v3/server/store" "go.woodpecker-ci.org/woodpecker/v3/server/store/datastore/migration" ) type storage struct { engine *xorm.Engine } const perPage = 50 func NewEngine(opts *store.Opts) (store.Store, error) { engine, err := xorm.NewEngine(opts.Driver, opts.Config) if err != nil { return nil, err } level := xlog.LogLevel(zerolog.GlobalLevel()) if !opts.XORM.Log { level = xlog.LOG_OFF } logger := newXORMLogger(level) engine.SetLogger(logger) engine.ShowSQL(opts.XORM.ShowSQL) engine.SetMaxOpenConns(opts.XORM.MaxOpenConns) engine.SetMaxIdleConns(opts.XORM.MaxIdleConns) engine.SetConnMaxLifetime(opts.XORM.ConnMaxLifetime) return &storage{ engine: engine, }, nil } func (s storage) Ping() error { return s.engine.Ping() } // Migrate old storage or init new one. func (s storage) Migrate(ctx context.Context, allowLong bool) error { return migration.Migrate(ctx, s.engine, allowLong) } func (s storage) Close() error { return s.engine.Close() } ================================================ FILE: server/store/datastore/engine_test.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "os" "testing" "time" "github.com/stretchr/testify/require" "xorm.io/xorm" "xorm.io/xorm/schemas" ) func testDriverConfig() (driver, config string) { driver = "sqlite3" config = ":memory:" if os.Getenv("WOODPECKER_DATABASE_DRIVER") != "" { driver = os.Getenv("WOODPECKER_DATABASE_DRIVER") config = os.Getenv("WOODPECKER_DATABASE_DATASOURCE") } return driver, config } // newTestStore creates a new database connection for testing purposes. // The database driver and connection string are provided by // environment variables, with fallback to in-memory sqlite. func newTestStore(t *testing.T, tables ...any) (store *storage, closer func()) { engine, err := xorm.NewEngine(testDriverConfig()) require.NoError(t, err) // MaxOpenConns=1 and MaxIdleConns=1 are required for in-memory sqlite: // without them the pool drops idle connections, destroying the in-memory // schema between calls and breaking migrations. engine.SetMaxOpenConns(1) engine.SetMaxIdleConns(1) for _, table := range tables { if err := engine.Sync(table); err != nil { t.Error(err) t.FailNow() } } return &storage{ engine: engine, }, func() { for _, bean := range tables { if err := engine.DropIndexes(bean); err != nil { t.Error(err) t.FailNow() } } if err := engine.DropTables(tables...); err != nil { t.Error(err) t.FailNow() } if err := engine.Close(); err != nil { t.Error(err) t.FailNow() } dbType := engine.Dialect().URI().DBType if dbType == schemas.MYSQL || dbType == schemas.POSTGRES { // wait for mysql/postgres to sync ... time.Sleep(10 * time.Millisecond) } } } ================================================ FILE: server/store/datastore/errors.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "fmt" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) type ErrorRepoNotExist struct { RepoID int64 } func (e ErrorRepoNotExist) Error() string { return fmt.Sprintf("Repo with %d is not existing", e.RepoID) } func (ErrorRepoNotExist) Unwrap() error { return types.ErrRecordNotExist } ================================================ FILE: server/store/datastore/feed.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "fmt" "xorm.io/builder" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) getFeedSelect() string { const feedTemplate = `repos.id as repo_id, pipelines.id as pipeline_id, pipelines.number as pipeline_number, pipelines.event as pipeline_event, pipelines.status as pipeline_status, pipelines.created as pipeline_created, pipelines.started as pipeline_started, pipelines.finished as pipeline_finished, pipelines.%s as pipeline_commit, pipelines.branch as pipeline_branch, pipelines.ref as pipeline_ref, pipelines.refspec as pipeline_refspec, pipelines.title as pipeline_title, pipelines.message as pipeline_message, pipelines.author as pipeline_author, pipelines.email as pipeline_email, pipelines.avatar as pipeline_avatar` return fmt.Sprintf(feedTemplate, s.engine.Dialect().Quoter().Quote("commit")) } func (s storage) GetPipelineQueue() ([]*model.Feed, error) { feed := make([]*model.Feed, 0, perPage) err := s.engine.Table("pipelines"). Select(s.getFeedSelect()). Join("INNER", "repos", "pipelines.repo_id = repos.id"). In("pipelines.status", model.StatusPending, model.StatusRunning). Find(&feed) return feed, err } func (s storage) UserFeed(user *model.User) ([]*model.Feed, error) { feed := make([]*model.Feed, 0, perPage) err := s.engine.Table("repos"). Select(s.getFeedSelect()). Join("INNER", "perms", "repos.id = perms.repo_id"). Join("INNER", "pipelines", "repos.id = pipelines.repo_id"). Where(userPushOrAdminCondition(user.ID)). Desc("pipelines.id"). Limit(perPage). Find(&feed) return feed, err } func (s storage) RepoListLatest(user *model.User) ([]*model.Feed, error) { feed := make([]*model.Feed, 0, perPage) err := s.engine.Table("repos"). Select(s.getFeedSelect()). Join("INNER", "perms", "repos.id = perms.repo_id"). Join("LEFT", "pipelines", "pipelines.id = "+`( SELECT pipelines.id FROM pipelines WHERE pipelines.repo_id = repos.id ORDER BY pipelines.id DESC LIMIT 1 )`). Where(userPushOrAdminCondition(user.ID)). And(builder.Eq{"repos.active": true}). Asc("repos.full_name"). Find(&feed) return feed, err } ================================================ FILE: server/store/datastore/feed_test.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestGetPipelineQueue(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org)) defer closer() user := &model.User{ Login: "joe", Email: "foo@bar.com", AccessToken: "e42080dddf012c718e476da161d21ad5", } assert.NoError(t, store.CreateUser(user)) repo1 := &model.Repo{ Owner: "bradrydzewski", Name: "test", FullName: "bradrydzewski/test", ForgeRemoteID: "1", IsActive: true, } assert.NoError(t, store.CreateRepo(repo1)) for _, perm := range []*model.Perm{ {UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false}, } { assert.NoError(t, store.PermUpsert(perm)) } pipeline1 := &model.Pipeline{ RepoID: repo1.ID, Status: model.StatusPending, Number: 1, Event: "push", Commit: "abc123", Branch: "main", Ref: "refs/heads/main", Message: "Initial commit", Author: "joe", Email: "foo@bar.com", Title: "First pipeline", } assert.NoError(t, store.CreatePipeline(pipeline1)) feed, err := store.GetPipelineQueue() assert.NoError(t, err) assert.Len(t, feed, 1) feedItem := feed[0] assert.Equal(t, repo1.ID, feedItem.RepoID) assert.Equal(t, pipeline1.ID, feedItem.ID) assert.Equal(t, pipeline1.Number, feedItem.Number) assert.EqualValues(t, pipeline1.Event, feedItem.Event) assert.EqualValues(t, pipeline1.Status, feedItem.Status) assert.Equal(t, pipeline1.Commit, feedItem.Commit) assert.Equal(t, pipeline1.Branch, feedItem.Branch) assert.Equal(t, pipeline1.Ref, feedItem.Ref) assert.Equal(t, pipeline1.Title, feedItem.Title) assert.Equal(t, pipeline1.Message, feedItem.Message) assert.Equal(t, pipeline1.Author, feedItem.Author) assert.Equal(t, pipeline1.Email, feedItem.Email) } func TestUserFeed(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org)) defer closer() user := &model.User{ Login: "joe", Email: "foo@bar.com", AccessToken: "e42080dddf012c718e476da161d21ad5", } assert.NoError(t, store.CreateUser(user)) repo1 := &model.Repo{ Owner: "bradrydzewski", Name: "test1", FullName: "bradrydzewski/test1", ForgeRemoteID: "1", IsActive: true, } repo2 := &model.Repo{ Owner: "johndoe", Name: "test", FullName: "johndoe/test2", ForgeRemoteID: "2", IsActive: true, } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) for _, perm := range []*model.Perm{ {UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false}, } { assert.NoError(t, store.PermUpsert(perm)) } pipeline1 := &model.Pipeline{ RepoID: repo1.ID, Status: model.StatusFailure, } assert.NoError(t, store.CreatePipeline(pipeline1)) feed, err := store.UserFeed(user) assert.NoError(t, err) assert.Len(t, feed, 1) } func TestRepoListLatest(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org)) defer closer() user := &model.User{ Login: "joe", Email: "foo@bar.com", AccessToken: "e42080dddf012c718e476da161d21ad5", } assert.NoError(t, store.CreateUser(user)) repo1 := &model.Repo{ ID: 1, Owner: "bradrydzewski", Name: "test", FullName: "bradrydzewski/test", ForgeRemoteID: "1", IsActive: true, } repo2 := &model.Repo{ ID: 2, Owner: "test", Name: "test", FullName: "test/test", ForgeRemoteID: "2", IsActive: true, } repo3 := &model.Repo{ ID: 3, Owner: "octocat", Name: "hello-world", FullName: "octocat/hello-world", ForgeRemoteID: "3", IsActive: true, } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) assert.NoError(t, store.CreateRepo(repo3)) for _, perm := range []*model.Perm{ {UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false}, {UserID: user.ID, RepoID: repo2.ID, Push: true, Admin: true}, } { assert.NoError(t, store.PermUpsert(perm)) } pipeline1 := &model.Pipeline{ RepoID: repo1.ID, Status: model.StatusFailure, } pipeline2 := &model.Pipeline{ RepoID: repo1.ID, Status: model.StatusRunning, } pipeline3 := &model.Pipeline{ RepoID: repo2.ID, Status: model.StatusKilled, } pipeline4 := &model.Pipeline{ RepoID: repo3.ID, Status: model.StatusError, } assert.NoError(t, store.CreatePipeline(pipeline1)) assert.NoError(t, store.CreatePipeline(pipeline2)) assert.NoError(t, store.CreatePipeline(pipeline3)) assert.NoError(t, store.CreatePipeline(pipeline4)) pipelines, err := store.RepoListLatest(user) assert.NoError(t, err) assert.Len(t, pipelines, 2) assert.EqualValues(t, model.StatusRunning, pipelines[0].Status) assert.Equal(t, repo1.ID, pipelines[0].RepoID) assert.EqualValues(t, model.StatusKilled, pipelines[1].Status) assert.Equal(t, repo2.ID, pipelines[1].RepoID) } ================================================ FILE: server/store/datastore/forge.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) ForgeGet(id int64) (*model.Forge, error) { forge := new(model.Forge) return forge, wrapGet(s.engine.ID(id).Get(forge)) } func (s storage) ForgeList(p *model.ListOptions) ([]*model.Forge, error) { forges := make([]*model.Forge, 0, 10) return forges, s.paginate(p).Find(&forges) } func (s storage) ForgeCreate(forge *model.Forge) error { // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(forge)) } func (s storage) ForgeUpdate(forge *model.Forge) error { _, err := s.engine.ID(forge.ID).AllCols().Update(forge) return err } func (s storage) ForgeDelete(forge *model.Forge) error { sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } if _, err := sess.ID(forge.ID).Delete(new(model.Forge)); err != nil { return err } return sess.Commit() } ================================================ FILE: server/store/datastore/forge_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestForgeCRUD(t *testing.T) { store, closer := newTestStore(t, new(model.Forge), new(model.Repo), new(model.User)) defer closer() forge1 := &model.Forge{ Type: "github", URL: "https://github.com", OAuthClientID: "client", OAuthClientSecret: "secret", SkipVerify: false, AdditionalOptions: map[string]any{ "foo": "bar", }, } // create first forge to play with assert.NoError(t, store.ForgeCreate(forge1)) assert.EqualValues(t, "github", forge1.Type) // retrieve it forgeOne, err := store.ForgeGet(forge1.ID) assert.NoError(t, err) assert.EqualValues(t, forge1, forgeOne) // change type assert.NoError(t, store.ForgeUpdate(&model.Forge{ID: forge1.ID, Type: "gitlab"})) // find updated forge by id forgeOne, err = store.ForgeGet(forge1.ID) assert.NoError(t, err) assert.EqualValues(t, "gitlab", forgeOne.Type) // create two more forges and repos someUser := &model.Forge{Type: "bitbucket"} assert.NoError(t, store.ForgeCreate(someUser)) assert.NoError(t, store.ForgeCreate(&model.Forge{Type: "gitea"})) // get all repos for a specific forge forges, err := store.ForgeList(&model.ListOptions{All: true}) assert.NoError(t, err) assert.Len(t, forges, 3) // delete an forge and check if it's gone assert.NoError(t, store.ForgeDelete(forge1)) _, err = store.ForgeGet(forge1.ID) assert.Error(t, err) } ================================================ FILE: server/store/datastore/helper.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "fmt" "runtime" "strings" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) // wrapGet return error if err not nil or if requested entry do not exist. func wrapGet(exist bool, err error) error { if !exist { return types.ErrRecordNotExist } if err != nil { // we only ask for the function's name if needed for performance reasons fnName := callerName(2) return fmt.Errorf("%s: %w", fnName, err) } return nil } // wrapDelete return error if err not nil or if requested entry do not exist. func wrapDelete(c int64, err error) error { if c == 0 { return types.ErrRecordNotExist } if err != nil { // we only ask for the function's name if needed for performance reasons fnName := callerName(2) return fmt.Errorf("%s: %w", fnName, err) } return nil } func wrapInsert(c int64, err error) error { if err != nil { if errMsg := err.Error(); strings.HasPrefix(errMsg, "UNIQUE constraint failed") || strings.HasPrefix(errMsg, "pq: duplicate key value violates unique constraint") || strings.Contains(errMsg, "Duplicate entry") { return types.ErrInsertDuplicateDetected } return err } return nil } func (s storage) paginate(p *model.ListOptions) *xorm.Session { if p == nil || p.All { return s.engine.NewSession() } if p.PerPage < 1 { p.PerPage = 1 } if p.Page < 1 { p.Page = 1 } return s.engine.Limit(p.PerPage, p.PerPage*(p.Page-1)) } func callerName(skip int) string { pc, _, _, ok := runtime.Caller(skip) if !ok { return "" } fnName := runtime.FuncForPC(pc).Name() pIndex := strings.LastIndex(fnName, ".") if pIndex != -1 { fnName = fnName[pIndex+1:] } return fnName } ================================================ FILE: server/store/datastore/helper_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "errors" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestWrapGet(t *testing.T) { err := wrapGet(false, nil) assert.ErrorIs(t, err, types.ErrRecordNotExist) err = wrapGet(true, errors.New("test err")) assert.Equal(t, "TestWrapGet: test err", err.Error()) } func TestWrapDelete(t *testing.T) { err := wrapDelete(0, nil) assert.ErrorIs(t, err, types.ErrRecordNotExist) err = wrapDelete(1, errors.New("test err")) assert.Equal(t, "TestWrapDelete: test err", err.Error()) } func TestWrapInsert(t *testing.T) { store, closer := newTestStore(t, new(model.Cron)) defer closer() // test normal insert cron := &model.Cron{RepoID: 1, CreatorID: 1, Name: "sync", NextExec: 10000, Schedule: "@every 1h"} assert.NoError(t, wrapInsert(store.engine.Insert(cron))) // test insert witch should fail because of unique constraint assert.ErrorIs(t, wrapInsert(store.engine.Insert(cron)), types.ErrInsertDuplicateDetected) } ================================================ FILE: server/store/datastore/init.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build !cgo package datastore import ( _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" ) // Supported database drivers. const ( DriverMysql = "mysql" DriverPostgres = "postgres" ) func SupportedDriver(driver string) bool { switch driver { case DriverMysql, DriverPostgres: return true default: return false } } ================================================ FILE: server/store/datastore/init_cgo.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build cgo package datastore import ( // Blank imports to register the sql drivers. _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" ) // Supported database drivers. const ( DriverSqlite = "sqlite3" DriverMysql = "mysql" DriverPostgres = "postgres" ) func SupportedDriver(driver string) bool { switch driver { case DriverMysql, DriverPostgres, DriverSqlite: return true default: return false } } ================================================ FILE: server/store/datastore/log.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "errors" "github.com/rs/zerolog/log" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // Maximum number of records to store in one PostgreSQL statement. // Too large a value results in `pq: got XX parameters but PostgreSQL only supports 65535 parameters`. const pgBatchSize = 1000 func (s storage) LogFind(step *model.Step) ([]*model.LogEntry, error) { var logEntries []*model.LogEntry return logEntries, s.engine.Asc("id").Where("step_id = ?", step.ID).Find(&logEntries) } func (s storage) LogAppend(_ *model.Step, logEntries []*model.LogEntry) error { var errs error // TODO: adapted from slices.Chunk(); switch to it in Go 1.23+ for i := 0; i < len(logEntries); i += pgBatchSize { end := min(pgBatchSize, len(logEntries[i:])) chunk := logEntries[i : i+end] if err := wrapInsert(s.engine.Insert(chunk)); err != nil { log.Error().Err(err).Msg("could not store log entries to db") errs = errors.Join(errs, err) } } return errs } func (s storage) LogDelete(step *model.Step) error { sess := s.engine.NewSession() defer sess.Close() return logDelete(sess, step.ID) } func logDelete(sess *xorm.Session, stepID int64) error { _, err := sess.Where("step_id = ?", stepID).Delete(new(model.LogEntry)) return err } func (s storage) StepFinished(_ *model.Step) {} ================================================ FILE: server/store/datastore/log_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestLogCreateFindDelete(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.LogEntry)) defer closer() step := model.Step{ ID: 1, } logEntries := []*model.LogEntry{ { StepID: step.ID, Data: []byte("hello"), Line: 1, Time: 0, }, { StepID: step.ID, Data: []byte("world"), Line: 2, Time: 10, }, } assert.NoError(t, store.LogAppend(&step, logEntries)) // we want to find our inserted logs _logEntries, err := store.LogFind(&step) assert.NoError(t, err) assert.Len(t, _logEntries, len(logEntries)) // delete and check assert.NoError(t, store.LogDelete(&step)) _logEntries, err = store.LogFind(&step) assert.NoError(t, err) assert.Len(t, _logEntries, 0) } func TestLogAppend(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.LogEntry)) defer closer() step := model.Step{ ID: 1, } logEntries := []*model.LogEntry{ { StepID: step.ID, Data: []byte("hello"), Line: 1, Time: 0, }, { StepID: step.ID, Data: []byte("world"), Line: 2, Time: 10, }, } assert.NoError(t, store.LogAppend(&step, logEntries)) logEntry := &model.LogEntry{ StepID: step.ID, Data: []byte("allo?"), Line: 3, Time: 20, } assert.NoError(t, store.LogAppend(&step, []*model.LogEntry{logEntry})) _logEntries, err := store.LogFind(&step) assert.NoError(t, err) assert.Len(t, _logEntries, len(logEntries)+1) } ================================================ FILE: server/store/datastore/migration/000_legacy_to_xormigrate.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var legacyToXormigrate = xormigrate.Migration{ ID: "legacy-to-xormigrate", MigrateSession: func(sess *xorm.Session) error { type migrations struct { Name string `xorm:"UNIQUE"` } var mig []*migrations if err := sess.Find(&mig); err != nil { return err } for _, m := range mig { if _, err := sess.Insert(&xormigrate.Migration{ ID: m.Name, }); err != nil { return err } } return sess.DropTable("migrations") }, } ================================================ FILE: server/store/datastore/migration/001_add_org_id.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var addOrgID = xormigrate.Migration{ ID: "add-org-id", MigrateSession: func(sess *xorm.Session) error { type users struct { ID int64 `xorm:"pk autoincr 'user_id'"` Login string `xorm:"UNIQUE 'user_login'"` OrgID int64 `xorm:"user_org_id"` } type orgs struct { ID int64 `xorm:"pk autoincr 'id'"` Name string `xorm:"UNIQUE 'name'"` IsUser bool `xorm:"is_user"` } if err := sess.Sync(new(users), new(orgs)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } // get all users var us []*users if err := sess.Find(&us); err != nil { return fmt.Errorf("find all repos failed: %w", err) } for _, user := range us { org := &orgs{} has, err := sess.Where("name = ?", user.Login).Get(org) if err != nil { return fmt.Errorf("getting org failed: %w", err) } else if !has { org = &orgs{ Name: user.Login, IsUser: true, } if _, err := sess.Insert(org); err != nil { return fmt.Errorf("inserting org failed: %w", err) } } user.OrgID = org.ID if _, err := sess.Cols("user_org_id").Update(user); err != nil { return fmt.Errorf("updating user failed: %w", err) } } return nil }, } ================================================ FILE: server/store/datastore/migration/002_task_data_type.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "xorm.io/xorm/schemas" ) var alterTableTasksUpdateColumnTaskDataType = xormigrate.Migration{ ID: "alter-table-tasks-update-type-of-task-data", MigrateSession: func(sess *xorm.Session) (err error) { dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.MYSQL: _, err = sess.Exec("ALTER TABLE tasks MODIFY COLUMN task_data LONGBLOB") default: // xorm uses the same type for all blob sizes in sqlite and postgres return nil } return err }, } ================================================ FILE: server/store/datastore/migration/003_config_data_type.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "xorm.io/xorm/schemas" ) var alterTableConfigUpdateColumnConfigDataType = xormigrate.Migration{ ID: "alter-table-config-update-type-of-config-data", MigrateSession: func(sess *xorm.Session) (err error) { dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.MYSQL: _, err = sess.Exec("ALTER TABLE config MODIFY COLUMN config_data LONGBLOB") default: // xorm uses the same type for all blob sizes in sqlite and postgres return nil } return err }, } ================================================ FILE: server/store/datastore/migration/004_remove_secrets_plugin_only_col.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var removePluginOnlyOptionFromSecretsTable = xormigrate.Migration{ ID: "remove-plugin-only-option-from-secrets-table", MigrateSession: func(sess *xorm.Session) (err error) { type secrets struct { ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"` PluginsOnly bool `json:"plugins_only" xorm:"secret_plugins_only"` SkipVerify bool `json:"-" xorm:"secret_skip_verify"` Conceal bool `json:"-" xorm:"secret_conceal"` Images []string `json:"images" xorm:"json 'secret_images'"` } // make sure plugin_only column exists if err := sess.Sync(new(secrets)); err != nil { return err } return dropTableColumns(sess, "secrets", "secret_plugins_only", "secret_skip_verify", "secret_conceal") }, } ================================================ FILE: server/store/datastore/migration/005_convert_to_new_pipeline_errors_format.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) // perPage005 set the size of the slice to read per page. var perPage005 = 100 var convertToNewPipelineErrorFormat = xormigrate.Migration{ ID: "convert-to-new-pipeline-error-format", Long: true, MigrateSession: func(sess *xorm.Session) (err error) { type pipelineError struct { Type string `json:"type"` Message string `json:"message"` IsWarning bool `json:"is_warning"` Data any `json:"data"` } type pipelines struct { ID int64 `json:"id" xorm:"pk autoincr 'pipeline_id'"` Error string `json:"error" xorm:"LONGTEXT 'pipeline_error'"` // old error format Errors []*pipelineError `json:"errors" xorm:"json 'pipeline_errors'"` // new error format } // make sure pipeline_error column exists if err := sess.Sync(new(pipelines)); err != nil { return err } page := 0 oldPipelines := make([]*pipelines, 0, perPage005) for { oldPipelines = oldPipelines[:0] err := sess.Limit(perPage005, page*perPage005).Cols("pipeline_id", "pipeline_error").Where("pipeline_error != ''").Find(&oldPipelines) if err != nil { return err } for _, oldPipeline := range oldPipelines { var newPipeline pipelines newPipeline.ID = oldPipeline.ID newPipeline.Errors = []*pipelineError{{ Type: "generic", Message: oldPipeline.Error, }} if _, err := sess.ID(oldPipeline.ID).Cols("pipeline_errors").Update(newPipeline); err != nil { return err } } if len(oldPipelines) < perPage005 { break } page++ } return dropTableColumns(sess, "pipelines", "pipeline_error") }, } ================================================ FILE: server/store/datastore/migration/006_link_to_url.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var renameLinkToURL = xormigrate.Migration{ ID: "rename-link-to-url", MigrateSession: func(sess *xorm.Session) (err error) { if err := renameColumn(sess, "pipelines", "pipeline_link", "pipeline_forge_url"); err != nil { return err } return renameColumn(sess, "repos", "repo_link", "repo_forge_url") }, } ================================================ FILE: server/store/datastore/migration/007_clean_registry_pipeline.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var cleanRegistryPipeline = xormigrate.Migration{ ID: "clean-registry-pipeline", MigrateSession: func(sess *xorm.Session) (err error) { type registry struct { ID int64 `json:"id" xorm:"pk autoincr 'registry_id'"` Token string `json:"token" xorm:"TEXT 'registry_token'"` Email string `json:"email" xorm:"varchar(500) 'registry_email'"` } type pipelines struct { ID int64 `json:"id" xorm:"pk autoincr 'pipeline_id'"` ConfigID int64 `json:"-" xorm:"pipeline_config_id"` Enqueued int64 `json:"enqueued_at" xorm:"pipeline_enqueued"` CloneURL string `json:"clone_url" xorm:"pipeline_clone_url"` } // ensure columns to drop exist if err := sess.Sync(new(registry), new(pipelines)); err != nil { return err } if err := dropTableColumns(sess, "pipelines", "pipeline_clone_url", "pipeline_config_id", "pipeline_enqueued"); err != nil { return err } return dropTableColumns(sess, "registry", "registry_email", "registry_token") }, } ================================================ FILE: server/store/datastore/migration/008_set_default_forge_id.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) type userV008 struct { ID int64 `xorm:"pk autoincr 'user_id'"` ForgeID int64 `xorm:"forge_id"` ForgeRemoteID model.ForgeRemoteID `xorm:"forge_remote_id"` Login string `xorm:"UNIQUE 'user_login'"` Token string `xorm:"TEXT 'user_token'"` Secret string `xorm:"TEXT 'user_secret'"` Expiry int64 `xorm:"user_expiry"` Email string `xorm:" varchar(500) 'user_email'"` Avatar string `xorm:" varchar(500) 'user_avatar'"` Admin bool `xorm:"user_admin"` Hash string `xorm:"UNIQUE varchar(500) 'user_hash'"` OrgID int64 `xorm:"user_org_id"` } func (userV008) TableName() string { return "users" } type repoV008 struct { ID int64 `xorm:"pk autoincr 'repo_id'"` UserID int64 `xorm:"repo_user_id"` ForgeID int64 `xorm:"forge_id"` ForgeRemoteID model.ForgeRemoteID `xorm:"forge_remote_id"` OrgID int64 `xorm:"repo_org_id"` Owner string `xorm:"UNIQUE(name) 'repo_owner'"` Name string `xorm:"UNIQUE(name) 'repo_name'"` FullName string `xorm:"UNIQUE 'repo_full_name'"` Avatar string `xorm:"varchar(500) 'repo_avatar'"` ForgeURL string `xorm:"varchar(1000) 'repo_forge_url'"` Clone string `xorm:"varchar(1000) 'repo_clone'"` CloneSSH string `xorm:"varchar(1000) 'repo_clone_ssh'"` Branch string `xorm:"varchar(500) 'repo_branch'"` PREnabled bool `xorm:"DEFAULT TRUE 'repo_pr_enabled'"` Timeout int64 `xorm:"repo_timeout"` Visibility model.RepoVisibility `xorm:"varchar(10) 'repo_visibility'"` IsSCMPrivate bool `xorm:"repo_private"` IsTrusted bool `xorm:"repo_trusted"` IsGated bool `xorm:"repo_gated"` IsActive bool `xorm:"repo_active"` AllowPull bool `xorm:"repo_allow_pr"` AllowDeploy bool `xorm:"repo_allow_deploy"` Config string `xorm:"varchar(500) 'repo_config_path'"` Hash string `xorm:"varchar(500) 'repo_hash'"` Perm *model.Perm `xorm:"-"` CancelPreviousPipelineEvents []model.WebhookEvent `xorm:"json 'cancel_previous_pipeline_events'"` NetrcOnlyTrusted bool `xorm:"NOT NULL DEFAULT true 'netrc_only_trusted'"` } func (repoV008) TableName() string { return "repos" } type forge struct { ID int64 `xorm:"pk autoincr 'id'"` Type model.ForgeType `xorm:"VARCHAR(250) 'type'"` URL string `xorm:"VARCHAR(500) 'url'"` Client string `xorm:"VARCHAR(250) 'client'"` ClientSecret string `xorm:"VARCHAR(250) 'client_secret'"` SkipVerify bool `xorm:"bool 'skip_verify'"` OAuthHost string `xorm:"VARCHAR(250) 'oauth_host'"` // public url for oauth if different from url AdditionalOptions map[string]any `xorm:"json 'additional_options'"` } func (forge) TableName() string { return "forge" } var setForgeID = xormigrate.Migration{ ID: "set-forge-id", MigrateSession: func(sess *xorm.Session) (err error) { if err := sess.Sync(new(userV008), new(repoV008), new(forge), new(model.Org)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } _, err = sess.Exec(fmt.Sprintf("UPDATE `%s` SET forge_id=1;", userV008{}.TableName())) if err != nil { return err } _, err = sess.Exec(fmt.Sprintf("UPDATE `%s` SET forge_id=1;", model.Org{}.TableName())) if err != nil { return err } _, err = sess.Exec(fmt.Sprintf("UPDATE `%s` SET forge_id=1;", repoV008{}.TableName())) return err }, } ================================================ FILE: server/store/datastore/migration/009_unify_columns_tables.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var unifyColumnsTables = xormigrate.Migration{ ID: "unify-columns-tables", MigrateSession: func(sess *xorm.Session) (err error) { type config struct { ID int64 `xorm:"pk autoincr 'config_id'"` RepoID int64 `xorm:"UNIQUE(s) 'config_repo_id'"` Hash string `xorm:"UNIQUE(s) 'config_hash'"` Name string `xorm:"UNIQUE(s) 'config_name'"` Data []byte `xorm:"LONGBLOB 'config_data'"` } type crons struct { ID int64 `xorm:"pk autoincr 'i_d'"` Name string `xorm:"name UNIQUE(s) INDEX"` RepoID int64 `xorm:"repo_id UNIQUE(s) INDEX"` CreatorID int64 `xorm:"creator_id INDEX"` NextExec int64 `xorm:"next_exec"` Schedule string `xorm:"schedule NOT NULL"` Created int64 `xorm:"created NOT NULL DEFAULT 0"` Branch string `xorm:"branch"` } type perms struct { UserID int64 `xorm:"UNIQUE(s) INDEX NOT NULL 'perm_user_id'"` RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL 'perm_repo_id'"` Pull bool `xorm:"perm_pull"` Push bool `xorm:"perm_push"` Admin bool `xorm:"perm_admin"` Synced int64 `xorm:"perm_synced"` } type pipelineError struct { Type string `json:"type"` Message string `json:"message"` IsWarning bool `json:"is_warning"` Data any `json:"data"` } type pipelines struct { ID int64 `xorm:"pk autoincr 'pipeline_id'"` RepoID int64 `xorm:"UNIQUE(s) INDEX 'pipeline_repo_id'"` Number int64 `xorm:"UNIQUE(s) 'pipeline_number'"` Author string `xorm:"INDEX 'pipeline_author'"` Parent int64 `xorm:"pipeline_parent"` Event string `xorm:"pipeline_event"` Status string `xorm:"INDEX 'pipeline_status'"` Errors []*pipelineError `xorm:"json 'pipeline_errors'"` Created int64 `xorm:"pipeline_created"` Started int64 `xorm:"pipeline_started"` Finished int64 `xorm:"pipeline_finished"` Deploy string `xorm:"pipeline_deploy"` DeployTask string `xorm:"pipeline_deploy_task"` Commit string `xorm:"pipeline_commit"` Branch string `xorm:"pipeline_branch"` Ref string `xorm:"pipeline_ref"` Refspec string `xorm:"pipeline_refspec"` Title string `xorm:"pipeline_title"` Message string `xorm:"TEXT 'pipeline_message'"` Timestamp int64 `xorm:"pipeline_timestamp"` Sender string `xorm:"pipeline_sender"` // uses reported user for webhooks and name of cron for cron pipelines Avatar string `xorm:"pipeline_avatar"` Email string `xorm:"pipeline_email"` ForgeURL string `xorm:"pipeline_forge_url"` Reviewer string `xorm:"pipeline_reviewer"` Reviewed int64 `xorm:"pipeline_reviewed"` } type redirections struct { ID int64 `xorm:"pk autoincr 'redirection_id'"` } type registry struct { ID int64 `xorm:"pk autoincr 'registry_id'"` RepoID int64 `xorm:"UNIQUE(s) INDEX 'registry_repo_id'"` Address string `xorm:"UNIQUE(s) INDEX 'registry_addr'"` Username string `xorm:"varchar(2000) 'registry_username'"` Password string `xorm:"TEXT 'registry_password'"` } type repos struct { ID int64 `xorm:"pk autoincr 'repo_id'"` UserID int64 `xorm:"repo_user_id"` OrgID int64 `xorm:"repo_org_id"` Owner string `xorm:"UNIQUE(name) 'repo_owner'"` Name string `xorm:"UNIQUE(name) 'repo_name'"` FullName string `xorm:"UNIQUE 'repo_full_name'"` Avatar string `xorm:"varchar(500) 'repo_avatar'"` ForgeURL string `xorm:"varchar(1000) 'repo_forge_url'"` Clone string `xorm:"varchar(1000) 'repo_clone'"` CloneSSH string `xorm:"varchar(1000) 'repo_clone_ssh'"` Branch string `xorm:"varchar(500) 'repo_branch'"` SCMKind string `xorm:"varchar(50) 'repo_scm'"` PREnabled bool `xorm:"DEFAULT TRUE 'repo_pr_enabled'"` Timeout int64 `xorm:"repo_timeout"` Visibility string `xorm:"varchar(10) 'repo_visibility'"` IsSCMPrivate bool `xorm:"repo_private"` IsTrusted bool `xorm:"repo_trusted"` IsGated bool `xorm:"repo_gated"` IsActive bool `xorm:"repo_active"` AllowPull bool `xorm:"repo_allow_pr"` AllowDeploy bool `xorm:"repo_allow_deploy"` Config string `xorm:"varchar(500) 'repo_config_path'"` Hash string `xorm:"varchar(500) 'repo_hash'"` } type secrets struct { ID int64 `xorm:"pk autoincr 'secret_id'"` OrgID int64 `xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"` RepoID int64 `xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"` Name string `xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"` Value string `xorm:"TEXT 'secret_value'"` Images []string `xorm:"json 'secret_images'"` Events []string `xorm:"json 'secret_events'"` } type steps struct { ID int64 `xorm:"pk autoincr 'step_id'"` UUID string `xorm:"INDEX 'step_uuid'"` PipelineID int64 `xorm:"UNIQUE(s) INDEX 'step_pipeline_id'"` PID int `xorm:"UNIQUE(s) 'step_pid'"` PPID int `xorm:"step_ppid"` Name string `xorm:"step_name"` State string `xorm:"step_state"` Error string `xorm:"TEXT 'step_error'"` Failure string `xorm:"step_failure"` ExitCode int `xorm:"step_exit_code"` Started int64 `xorm:"step_started"` Stopped int64 `xorm:"step_stopped"` Type string `xorm:"step_type"` } type tasks struct { ID string `xorm:"PK UNIQUE 'task_id'"` Data []byte `xorm:"LONGBLOB 'task_data'"` Labels map[string]string `xorm:"json 'task_labels'"` Dependencies []string `xorm:"json 'task_dependencies'"` RunOn []string `xorm:"json 'task_run_on'"` DepStatus map[string]string `xorm:"json 'task_dep_status'"` } type users struct { ID int64 `xorm:"pk autoincr 'user_id'"` Login string `xorm:"UNIQUE 'user_login'"` Token string `xorm:"TEXT 'user_token'"` Secret string `xorm:"TEXT 'user_secret'"` Expiry int64 `xorm:"user_expiry"` Email string `xorm:" varchar(500) 'user_email'"` Avatar string `xorm:" varchar(500) 'user_avatar'"` Admin bool `xorm:"user_admin"` Hash string `xorm:"UNIQUE varchar(500) 'user_hash'"` OrgID int64 `xorm:"user_org_id"` } type workflows struct { ID int64 `xorm:"pk autoincr 'workflow_id'"` PipelineID int64 `xorm:"UNIQUE(s) INDEX 'workflow_pipeline_id'"` PID int `xorm:"UNIQUE(s) 'workflow_pid'"` Name string `xorm:"workflow_name"` State string `xorm:"workflow_state"` Error string `xorm:"TEXT 'workflow_error'"` Started int64 `xorm:"workflow_started"` Stopped int64 `xorm:"workflow_stopped"` AgentID int64 `xorm:"workflow_agent_id"` Platform string `xorm:"workflow_platform"` Environ map[string]string `xorm:"json 'workflow_environ'"` AxisID int `xorm:"workflow_axis_id"` } type serverConfig struct { Key string `xorm:"pk 'key'"` Value string `xorm:"value"` } if err := sess.Sync(new(config), new(crons), new(perms), new(pipelines), new(redirections), new(registry), new(repos), new(secrets), new(steps), new(tasks), new(users), new(workflows), new(serverConfig)); err != nil { return fmt.Errorf("sync models failed: %w", err) } // Config if err := renameColumn(sess, "config", "config_id", "id"); err != nil { return err } if err := renameColumn(sess, "config", "config_repo_id", "repo_id"); err != nil { return err } if err := renameColumn(sess, "config", "config_hash", "hash"); err != nil { return err } if err := renameColumn(sess, "config", "config_name", "name"); err != nil { return err } if err := renameColumn(sess, "config", "config_data", "data"); err != nil { return err } if err := renameTable(sess, "config", "configs"); err != nil { return err } // PipelineConfig if err := renameTable(sess, "pipeline_config", "pipeline_configs"); err != nil { return err } // Cron if err := renameColumn(sess, "crons", "i_d", "id"); err != nil { return err } // Forge if err := renameTable(sess, "forge", "forges"); err != nil { return err } // Perm if err := renameColumn(sess, "perms", "perm_user_id", "user_id"); err != nil { return err } if err := renameColumn(sess, "perms", "perm_repo_id", "repo_id"); err != nil { return err } if err := renameColumn(sess, "perms", "perm_pull", "pull"); err != nil { return err } if err := renameColumn(sess, "perms", "perm_push", "push"); err != nil { return err } if err := renameColumn(sess, "perms", "perm_admin", "admin"); err != nil { return err } if err := renameColumn(sess, "perms", "perm_synced", "synced"); err != nil { return err } // Pipeline if err := renameColumn(sess, "pipelines", "pipeline_id", "id"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_repo_id", "repo_id"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_number", "number"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_author", "author"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_parent", "parent"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_event", "event"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_status", "status"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_errors", "errors"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_created", "created"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_started", "started"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_finished", "finished"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_deploy", "deploy"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_deploy_task", "deploy_task"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_commit", "commit"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_branch", "branch"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_ref", "ref"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_refspec", "refspec"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_title", "title"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_message", "message"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_timestamp", "timestamp"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_sender", "sender"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_avatar", "avatar"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_email", "email"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_forge_url", "forge_url"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_reviewer", "reviewer"); err != nil { return err } if err := renameColumn(sess, "pipelines", "pipeline_reviewed", "reviewed"); err != nil { return err } // Redirection if err := renameColumn(sess, "redirections", "redirection_id", "id"); err != nil { return err } // Registry if err := renameColumn(sess, "registry", "registry_id", "id"); err != nil { return err } if err := renameColumn(sess, "registry", "registry_repo_id", "repo_id"); err != nil { return err } if err := renameColumn(sess, "registry", "registry_addr", "address"); err != nil { return err } if err := renameColumn(sess, "registry", "registry_username", "username"); err != nil { return err } if err := renameColumn(sess, "registry", "registry_password", "password"); err != nil { return err } if err := renameTable(sess, "registry", "registries"); err != nil { return err } // Repo if err := renameColumn(sess, "repos", "repo_id", "id"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_user_id", "user_id"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_org_id", "org_id"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_owner", "owner"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_name", "name"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_full_name", "full_name"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_avatar", "avatar"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_forge_url", "forge_url"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_clone", "clone"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_clone_ssh", "clone_ssh"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_branch", "branch"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_scm", "scm"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_pr_enabled", "pr_enabled"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_timeout", "timeout"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_visibility", "visibility"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_private", "private"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_trusted", "trusted"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_gated", "gated"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_active", "active"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_allow_pr", "allow_pr"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_allow_deploy", "allow_deploy"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_config_path", "config_path"); err != nil { return err } if err := renameColumn(sess, "repos", "repo_hash", "hash"); err != nil { return err } // Secrets if err := renameColumn(sess, "secrets", "secret_id", "id"); err != nil { return err } if err := renameColumn(sess, "secrets", "secret_org_id", "org_id"); err != nil { return err } if err := renameColumn(sess, "secrets", "secret_repo_id", "repo_id"); err != nil { return err } if err := renameColumn(sess, "secrets", "secret_name", "name"); err != nil { return err } if err := renameColumn(sess, "secrets", "secret_value", "value"); err != nil { return err } if err := renameColumn(sess, "secrets", "secret_images", "images"); err != nil { return err } if err := renameColumn(sess, "secrets", "secret_events", "events"); err != nil { return err } // ServerConfig if err := renameTable(sess, "server_config", "server_configs"); err != nil { return err } // Step if err := renameColumn(sess, "steps", "step_id", "id"); err != nil { return err } if err := renameColumn(sess, "steps", "step_uuid", "uuid"); err != nil { return err } if err := renameColumn(sess, "steps", "step_pipeline_id", "pipeline_id"); err != nil { return err } if err := renameColumn(sess, "steps", "step_pid", "pid"); err != nil { return err } if err := renameColumn(sess, "steps", "step_ppid", "ppid"); err != nil { return err } if err := renameColumn(sess, "steps", "step_name", "name"); err != nil { return err } if err := renameColumn(sess, "steps", "step_state", "state"); err != nil { return err } if err := renameColumn(sess, "steps", "step_error", "error"); err != nil { return err } if err := renameColumn(sess, "steps", "step_failure", "failure"); err != nil { return err } if err := renameColumn(sess, "steps", "step_exit_code", "exit_code"); err != nil { return err } if err := renameColumn(sess, "steps", "step_started", "started"); err != nil { return err } if err := renameColumn(sess, "steps", "step_stopped", "stopped"); err != nil { return err } if err := renameColumn(sess, "steps", "step_type", "type"); err != nil { return err } // Task if err := renameColumn(sess, "tasks", "task_id", "id"); err != nil { return err } if err := renameColumn(sess, "tasks", "task_data", "data"); err != nil { return err } if err := renameColumn(sess, "tasks", "task_labels", "labels"); err != nil { return err } if err := renameColumn(sess, "tasks", "task_dependencies", "dependencies"); err != nil { return err } if err := renameColumn(sess, "tasks", "task_run_on", "run_on"); err != nil { return err } if err := renameColumn(sess, "tasks", "task_dep_status", "dependencies_status"); err != nil { return err } // User if err := renameColumn(sess, "users", "user_id", "id"); err != nil { return err } if err := renameColumn(sess, "users", "user_login", "login"); err != nil { return err } if err := renameColumn(sess, "users", "user_token", "token"); err != nil { return err } if err := renameColumn(sess, "users", "user_secret", "secret"); err != nil { return err } if err := renameColumn(sess, "users", "user_expiry", "expiry"); err != nil { return err } if err := renameColumn(sess, "users", "user_email", "email"); err != nil { return err } if err := renameColumn(sess, "users", "user_avatar", "avatar"); err != nil { return err } if err := renameColumn(sess, "users", "user_admin", "admin"); err != nil { return err } if err := renameColumn(sess, "users", "user_hash", "hash"); err != nil { return err } if err := renameColumn(sess, "users", "user_org_id", "org_id"); err != nil { return err } // Workflow if err := renameColumn(sess, "workflows", "workflow_id", "id"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_pipeline_id", "pipeline_id"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_pid", "pid"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_name", "name"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_state", "state"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_error", "error"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_started", "started"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_stopped", "stopped"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_agent_id", "agent_id"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_platform", "platform"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_environ", "environ"); err != nil { return err } if err := renameColumn(sess, "workflows", "workflow_axis_id", "axis_id"); err != nil { return err } return nil }, } ================================================ FILE: server/store/datastore/migration/010_registries_add_user.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var alterTableRegistriesFixRequiredFields = xormigrate.Migration{ ID: "alter-table-registries-fix-required-fields", MigrateSession: func(sess *xorm.Session) error { if err := alterColumnDefault(sess, "registries", "repo_id", "0"); err != nil { return err } if err := alterColumnNull(sess, "registries", "repo_id", false); err != nil { return err } return alterColumnNull(sess, "registries", "address", false) }, } ================================================ FILE: server/store/datastore/migration/011_cron_without_sec.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "strings" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var cronWithoutSec = xormigrate.Migration{ ID: "cron-without-sec", MigrateSession: func(sess *xorm.Session) error { if err := sess.Sync(new(model.Cron)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } var crons []*model.Cron if err := sess.Find(&crons); err != nil { return err } for _, c := range crons { if strings.HasPrefix(strings.TrimSpace(c.Schedule), "@") { // something like "@daily" continue } if _, err := sess.Update(&model.Cron{ Schedule: strings.SplitN(strings.TrimSpace(c.Schedule), " ", 2)[1], }, c); err != nil { return err } } return nil }, } ================================================ FILE: server/store/datastore/migration/012_rename_start_end_time.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var renameStartEndTime = xormigrate.Migration{ ID: "rename-start-end-time", MigrateSession: func(sess *xorm.Session) (err error) { type steps struct { Finished int64 `xorm:"stopped"` } type workflows struct { Finished int64 `xorm:"stopped"` } if err := sess.Sync(new(steps), new(workflows)); err != nil { return fmt.Errorf("sync models failed: %w", err) } // Step if err := renameColumn(sess, "steps", "stopped", "finished"); err != nil { return err } // Workflow if err := renameColumn(sess, "workflows", "stopped", "finished"); err != nil { return err } return nil }, } ================================================ FILE: server/store/datastore/migration/013_fix_v31_registries.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var fixV31Registries = xormigrate.Migration{ ID: "fix-v31-registries", MigrateSession: func(sess *xorm.Session) (err error) { has, err := sess.IsTableExist("registry_v031") if err != nil { return err } if has { return sess.DropTable("registry_v031") } return nil }, } ================================================ FILE: server/store/datastore/migration/014_remove_old_migrations_of_v1.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var removeOldMigrationsOfV1 = xormigrate.Migration{ ID: "remove-old-migrations-of-v1", MigrateSession: func(sess *xorm.Session) (err error) { _, err = sess.Table(&xormigrate.Migration{}).In("id", []string{ "xorm", "alter-table-drop-repo-fallback", "drop-allow-push-tags-deploys-columns", "fix-pr-secret-event-name", "alter-table-drop-counter", "drop-senders", "alter-table-logs-update-type-of-data", "alter-table-add-secrets-user-id", "lowercase-secret-names", "recreate-agents-table", "rename-builds-to-pipeline", "rename-columns-builds-to-pipeline", "rename-procs-to-steps", "rename-remote-to-forge", "rename-forge-id-to-forge-remote-id", "remove-active-from-users", "remove-inactive-repos", "drop-files", "remove-machine-col", "drop-old-col", "init-log_entries", "migrate-logs-to-log_entries", "parent-steps-to-workflows", "add-orgs", }).Delete() return err }, } ================================================ FILE: server/store/datastore/migration/015_add_org_agents.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var addOrgAgents = xormigrate.Migration{ ID: "add-org-agents", MigrateSession: func(sess *xorm.Session) (err error) { type agents struct { ID int64 `xorm:"pk autoincr 'id'"` OwnerID int64 `xorm:"INDEX 'owner_id'"` OrgID int64 `xorm:"INDEX 'org_id'"` } if err := sess.Sync(new(agents)); err != nil { return fmt.Errorf("sync models failed: %w", err) } // Update all existing agents to be global agents _, err = sess.Cols("org_id").Update(&agents{ OrgID: model.IDNotSet, }) return err }, } ================================================ FILE: server/store/datastore/migration/016_add_custom_labels_to_agent.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var addCustomLabelsToAgent = xormigrate.Migration{ ID: "add-custom-labels-to-agent", MigrateSession: func(sess *xorm.Session) (err error) { type agents struct { ID int64 `xorm:"pk autoincr 'id'"` CustomLabels map[string]string `xorm:"JSON 'custom_labels'"` } if err := sess.Sync(new(agents)); err != nil { return fmt.Errorf("sync models failed: %w", err) } return nil }, } ================================================ FILE: server/store/datastore/migration/017_split_trusted.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var splitTrusted = xormigrate.Migration{ ID: "split-trusted", MigrateSession: func(sess *xorm.Session) error { type repos struct { ID int64 `xorm:"pk autoincr 'id'"` IsTrusted bool `xorm:"'trusted'"` Trusted model.TrustedConfiguration `xorm:"json 'trusted_conf'"` } if err := sess.Sync(new(repos)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } if _, err := sess.Where("trusted = ?", false).Cols("trusted_conf").Update(&repos{ Trusted: model.TrustedConfiguration{ Network: false, Security: false, Volumes: false, }, }); err != nil { return err } if _, err := sess.Where("trusted = ?", true).Cols("trusted_conf").Update(&repos{ Trusted: model.TrustedConfiguration{ Network: true, Security: true, Volumes: true, }, }); err != nil { return err } if err := dropTableColumns(sess, "repos", "trusted"); err != nil { return err } if err := sess.Commit(); err != nil { return err } return renameColumn(sess, "repos", "trusted_conf", "trusted") }, } ================================================ FILE: server/store/datastore/migration/018_fix_orgs_users_match.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "xorm.io/xorm/schemas" ) var correctPotentialCorruptOrgsUsersRelation = xormigrate.Migration{ ID: "correct-potential-corrupt-orgs-users-relation", MigrateSession: func(sess *xorm.Session) error { type users struct { ID int64 `xorm:"pk autoincr 'id'"` ForgeID int64 `xorm:"forge_id"` Login string `xorm:"UNIQUE 'login'"` OrgID int64 `xorm:"org_id"` } type orgs struct { ID int64 `xorm:"pk autoincr 'id'"` ForgeID int64 `xorm:"forge_id"` Name string `xorm:"UNIQUE 'name'"` } if err := sess.Sync(new(users), new(orgs)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } dialect := sess.Engine().Dialect().URI().DBType var err error switch dialect { case schemas.MYSQL: _, err = sess.Exec(`UPDATE users u JOIN orgs o ON o.name = u.login AND o.forge_id = u.forge_id SET u.org_id = o.id;`) case schemas.POSTGRES: _, err = sess.Exec(`UPDATE users u SET org_id = o.id FROM orgs o WHERE o.name = u.login AND o.forge_id = u.forge_id;`) case schemas.SQLITE: _, err = sess.Exec(`UPDATE users SET org_id = ( SELECT orgs.id FROM orgs WHERE orgs.name = users.login AND orgs.forge_id = users.forge_id ) WHERE users.login IN (SELECT orgs.name FROM orgs);`) default: err = fmt.Errorf("dialect '%s' not supported", dialect) } return err }, } ================================================ FILE: server/store/datastore/migration/019_gated_to_require_approval.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/builder" "xorm.io/xorm" ) var gatedToRequireApproval = xormigrate.Migration{ ID: "gated-to-require-approval", MigrateSession: func(sess *xorm.Session) (err error) { const ( requireApprovalOldNotGated string = "old_not_gated" requireApprovalAllEvents string = "all_events" ) type repos struct { ID int64 `xorm:"pk autoincr 'id'"` IsGated bool `xorm:"gated"` RequireApproval string `xorm:"require_approval"` Visibility string `xorm:"varchar(10) 'visibility'"` } if err := sess.Sync(new(repos)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } // migrate gated repos if _, err := sess.Exec( builder.Update(builder.Eq{"require_approval": requireApprovalAllEvents}). From("repos"). Where(builder.Eq{"gated": true})); err != nil { return err } // migrate non gated repos to old_not_gated (no approval required) if _, err := sess.Exec( builder.Update(builder.Eq{"require_approval": requireApprovalOldNotGated}). From("repos"). Where(builder.Eq{"gated": false})); err != nil { return err } return dropTableColumns(sess, "repos", "gated") }, } ================================================ FILE: server/store/datastore/migration/020_remove_repo_netrc_only_trusted.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var removeRepoNetrcOnlyTrusted = xormigrate.Migration{ ID: "remove-repo-netrc-only-trusted", MigrateSession: func(sess *xorm.Session) (err error) { type repos struct { NetrcOnlyTrusted string `xorm:"netrc_only_trusted"` } // ensure columns to drop exist if err := sess.Sync(new(repos)); err != nil { return err } return dropTableColumns(sess, "repos", "netrc_only_trusted") }, } ================================================ FILE: server/store/datastore/migration/021_rename_token_fields.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var renameTokenFields = xormigrate.Migration{ ID: "rename-token-fields", MigrateSession: func(sess *xorm.Session) (err error) { type users struct { AccessToken string `xorm:"TEXT 'token'"` RefreshToken string `xorm:"TEXT 'secret'"` } // ensure columns to rename exist if err := sess.Sync(new(users)); err != nil { return err } if err := renameColumn(sess, "users", "token", "access_token"); err != nil { return err } return renameColumn(sess, "users", "secret", "refresh_token") }, } ================================================ FILE: server/store/datastore/migration/022_set_new_defaults_for_require_approval.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/builder" "xorm.io/xorm" ) var setNewDefaultsForRequireApproval = xormigrate.Migration{ ID: "set-new-defaults-for-require-approval", MigrateSession: func(sess *xorm.Session) (err error) { const ( RequireApprovalOldNotGated string = "old_not_gated" RequireApprovalNone string = "none" RequireApprovalForks string = "forks" RequireApprovalAllEvents string = "all_events" ) type repos struct { RequireApproval string `xorm:"require_approval"` Visibility string `xorm:"varchar(10) 'visibility'"` } if err := sess.Sync(new(repos)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } // migrate public repos to require approval for forks if _, err := sess.Exec( builder.Update(builder.Eq{"require_approval": RequireApprovalForks}). From("repos"). Where(builder.Eq{"require_approval": RequireApprovalOldNotGated, "visibility": "public"})); err != nil { return err } // migrate private repos to require no approval if _, err := sess.Exec( builder.Update(builder.Eq{"require_approval": RequireApprovalNone}). From("repos"). Where(builder.Eq{"require_approval": RequireApprovalOldNotGated}.And(builder.Neq{"visibility": "public"}))); err != nil { return err } return nil }, } ================================================ FILE: server/store/datastore/migration/023_remove_repo_scm.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var removeRepoScm = xormigrate.Migration{ ID: "remove-repo-scm", MigrateSession: func(sess *xorm.Session) (err error) { type repos struct { SCMKind string `xorm:"varchar(50) 'scm'"` } // ensure columns to drop exist if err := sess.Sync(new(repos)); err != nil { return err } return dropTableColumns(sess, "repos", "scm") }, } ================================================ FILE: server/store/datastore/migration/024_unsanitize_org_and_user_names.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "src.techknowlogick.com/xormigrate" "xorm.io/builder" "xorm.io/xorm" ) var unsanitizeOrgAndUserNames = xormigrate.Migration{ ID: "unsanitize-org-and-user-names", MigrateSession: func(sess *xorm.Session) (err error) { type user struct { ID int64 `xorm:"pk autoincr 'id'"` Login string `xorm:"TEXT 'login'"` ForgeID int64 `xorm:"forge_id"` } type org struct { ID int64 `xorm:"pk autoincr 'id'"` Name string `xorm:"TEXT 'name'"` ForgeID int64 `xorm:"forge_id"` } if err := sess.Sync(new(user), new(org)); err != nil { return fmt.Errorf("sync new models failed: %w", err) } // get all users var users []*user if err := sess.Find(&users); err != nil { return fmt.Errorf("find all repos failed: %w", err) } for _, user := range users { userOrg := &org{} _, err := sess.Where("name = ? AND forge_id = ?", user.Login, user.ForgeID).Get(userOrg) if err != nil { return fmt.Errorf("getting org failed: %w", err) } if user.Login != userOrg.Name { userOrg.Name = user.Login if _, err := sess.Where(builder.Eq{"id": userOrg.ID}).Cols("Name").Update(userOrg); err != nil { return fmt.Errorf("updating org name failed: %w", err) } } } return nil }, } ================================================ FILE: server/store/datastore/migration/025_fix_zero_forge_id_ref.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var replaceZeroForgeIDsInOrgs = xormigrate.Migration{ ID: "replace-zero-forge-ids-in-orgs", MigrateSession: func(sess *xorm.Session) (err error) { _, err = sess.Exec("UPDATE orgs SET forge_id=1 WHERE forge_id=0;") return err }, } ================================================ FILE: server/store/datastore/migration/026_fix_forge_columns.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" ) var fixForgeColumns = xormigrate.Migration{ ID: "fix-forge-columns", MigrateSession: func(sess *xorm.Session) (err error) { type forges struct { ID int64 `xorm:"pk autoincr 'id'"` Client string `xorm:"VARCHAR(250) 'client'"` ClientSecret string `xorm:"VARCHAR(250) 'client_secret'"` OAuthClientID string `xorm:"VARCHAR(250) 'o_auth_client_i_d'"` OAuthClientSecret string `xorm:"VARCHAR(250) 'o_auth_client_secret'"` } // Ensure columns to rename exist if err := sess.Sync(new(forges)); err != nil { return err } // Rename old columns to new names if err := renameColumn(sess, "forges", "o_auth_client_i_d", "oauth_client_id"); err != nil { return err } if err := renameColumn(sess, "forges", "o_auth_client_secret", "oauth_client_secret"); err != nil { return err } // Drop client and client_secret columns if they still exist return dropTableColumns(sess, "forges", "client", "client_secret") }, } ================================================ FILE: server/store/datastore/migration/027_add_cron_field.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) var addCronField = xormigrate.Migration{ ID: "add-cron-field", MigrateSession: func(sess *xorm.Session) error { type pipelines struct { ID int64 `xorm:"pk autoincr 'id'"` // new cron field Cron string `xorm:"cron"` } if err := sess.Sync(new(pipelines)); err != nil { return err } _, err := sess.Exec("UPDATE pipelines SET cron = sender, sender = '', message = '' WHERE event = ?", model.EventCron) return err }, } ================================================ FILE: server/store/datastore/migration/common.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "regexp" "strings" "xorm.io/xorm" "xorm.io/xorm/schemas" ) func renameTable(sess *xorm.Session, oldTable, newTable string) error { dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.MYSQL: _, err := sess.Exec(fmt.Sprintf("RENAME TABLE `%s` TO `%s`;", oldTable, newTable)) return err case schemas.POSTGRES, schemas.SQLITE: _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`;", oldTable, newTable)) return err default: return fmt.Errorf("dialect '%s' not supported", dialect) } } // WARNING: YOU MUST COMMIT THE SESSION AT THE END. func dropTableColumns(sess *xorm.Session, tableName string, columnNames ...string) (err error) { // Copyright 2017 The Gitea Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. if tableName == "" || len(columnNames) == 0 { return nil } // TODO: This will not work if there are foreign keys dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.SQLITE: // First drop the indexes on the columns res, errIndex := sess.Query(fmt.Sprintf("PRAGMA index_list(`%s`)", tableName)) if errIndex != nil { return errIndex } for _, row := range res { indexName := row["name"] indexRes, err := sess.Query(fmt.Sprintf("PRAGMA index_info(`%s`)", indexName)) if err != nil { return err } if len(indexRes) != 1 { continue } indexColumn := string(indexRes[0]["name"]) for _, name := range columnNames { if name == indexColumn { _, err := sess.Exec(fmt.Sprintf("DROP INDEX `%s`", indexName)) if err != nil { return err } } } } // Now drop the columns for _, columnName := range columnNames { _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` DROP COLUMN `%s`;", tableName, columnName)) if err != nil { return fmt.Errorf("table `%s`, drop column %v: %w", tableName, columnName, err) } } case schemas.POSTGRES: cols := "" for _, col := range columnNames { if cols != "" { cols += ", " } cols += "DROP COLUMN `" + col + "` CASCADE" } if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` %s", tableName, cols)); err != nil { return fmt.Errorf("table `%s`, drop columns %v: %w", tableName, columnNames, err) } case schemas.MYSQL: // Drop indexes on columns first sql := fmt.Sprintf("SHOW INDEX FROM %s WHERE column_name IN ('%s')", tableName, strings.Join(columnNames, "','")) res, err := sess.Query(sql) if err != nil { return err } for _, index := range res { indexName := index["column_name"] if len(indexName) > 0 { _, err := sess.Exec(fmt.Sprintf("DROP INDEX `%s` ON `%s`", indexName, tableName)) if err != nil { return err } } } // Now drop the columns cols := "" for _, col := range columnNames { if cols != "" { cols += ", " } cols += "DROP COLUMN `" + col + "`" } if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` %s", tableName, cols)); err != nil { return fmt.Errorf("table `%s`, drop columns %v: %w", tableName, columnNames, err) } default: return fmt.Errorf("dialect '%s' not supported", dialect) } return nil } func alterColumnDefault(sess *xorm.Session, table, column, defValue string) error { dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.MYSQL: sql := fmt.Sprintf("SHOW COLUMNS FROM `%s` WHERE lower(field) = '%s'", table, strings.ToLower(column)) res, err := sess.Query(sql) if err != nil { return err } if len(res) == 0 || len(res[0]["Type"]) == 0 { return fmt.Errorf("column %s data type in table %s can not be detected", column, table) } dataType := string(res[0]["Type"]) var nullable string if string(res[0]["Null"]) == "NO" { nullable = "NOT NULL" } _, err = sess.Exec(fmt.Sprintf("ALTER TABLE `%s` MODIFY `%s` %s %s DEFAULT %s;", table, column, dataType, nullable, defValue)) return err case schemas.POSTGRES: _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` ALTER COLUMN `%s` SET DEFAULT %s;", table, column, defValue)) return err case schemas.SQLITE: return nil default: return fmt.Errorf("dialect '%s' not supported", dialect) } } func alterColumnNull(sess *xorm.Session, table, column string, null bool) error { val := "NULL" if !null { val = "NOT NULL" } dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.MYSQL: sql := fmt.Sprintf("SHOW COLUMNS FROM `%s` WHERE lower(field) = '%s'", table, strings.ToLower(column)) res, err := sess.Query(sql) if err != nil { return err } if len(res) == 0 || len(res[0]["Type"]) == 0 { return fmt.Errorf("column %s data type in table %s can not be detected", column, table) } dataType := string(res[0]["Type"]) defValue := string(res[0]["Default"]) if defValue != "NULL" && defValue != "" { defValue = fmt.Sprintf("DEFAULT '%s'", defValue) } else { defValue = "" } _, err = sess.Exec(fmt.Sprintf("ALTER TABLE `%s` MODIFY `%s` %s %s %s;", table, column, dataType, val, defValue)) return err case schemas.POSTGRES: _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` ALTER COLUMN `%s` SET %s;", table, column, val)) return err case schemas.SQLITE: return nil default: return fmt.Errorf("dialect '%s' not supported", dialect) } } func renameColumn(sess *xorm.Session, table, column, newName string) error { dialect := sess.Engine().Dialect().URI().DBType switch dialect { case schemas.MYSQL, schemas.POSTGRES, schemas.SQLITE: _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `%s` RENAME COLUMN `%s` TO `%s`;", table, column, newName)) return err default: return fmt.Errorf("dialect '%s' not supported", dialect) } } var ( whitespaces = regexp.MustCompile(`\s+`) columnSeparator = regexp.MustCompile(`\s?,\s?`) ) func removeColumnFromSQLITETableSchema(schema string, names ...string) string { if len(names) == 0 { return schema } for i := range names { if len(names[i]) == 0 { continue } schema = regexp.MustCompile(`\s(`+ regexp.QuoteMeta("`"+names[i]+"`")+ "|"+ regexp.QuoteMeta(names[i])+ ")[^`,)]*?[,)]").ReplaceAllString(schema, "") } return schema } func normalizeSQLiteTableSchema(schema string) string { return columnSeparator.ReplaceAllString( whitespaces.ReplaceAllString( strings.ReplaceAll(schema, "\n", " "), " "), ", ") } ================================================ FILE: server/store/datastore/migration/common_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "testing" "github.com/stretchr/testify/assert" ) func TestRemoveColumnFromSQLITETableSchema(t *testing.T) { schema := "CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, repo_user_id INTEGER, repo_owner TEXT, " + "repo_name TEXT, repo_full_name TEXT, `repo_avatar` TEXT, repo_branch TEXT, repo_timeout INTEGER, " + "repo_allow_pr BOOLEAN, repo_config_path TEXT, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, " + "repo_fallback BOOLEAN, UNIQUE(repo_full_name) )" assert.EqualValues(t, schema, removeColumnFromSQLITETableSchema(schema, "")) assert.EqualValues(t, "CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, repo_user_id INTEGER, repo_owner TEXT, "+ "repo_name TEXT, repo_full_name TEXT, repo_branch TEXT, repo_timeout INTEGER, "+ "repo_allow_pr BOOLEAN, repo_config_path TEXT, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, "+ "repo_fallback BOOLEAN, UNIQUE(repo_full_name) )", removeColumnFromSQLITETableSchema(schema, "repo_avatar")) assert.EqualValues(t, "CREATE TABLE repos ( repo_user_id INTEGER, repo_owner TEXT, "+ "repo_name TEXT, repo_full_name TEXT, `repo_avatar` TEXT, repo_timeout INTEGER, "+ "repo_allow_pr BOOLEAN, repo_config_path TEXT, repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, "+ "repo_fallback BOOLEAN, UNIQUE(repo_full_name) )", removeColumnFromSQLITETableSchema(schema, "repo_id", "repo_branch", "invalid", "")) } func TestNormalizeSQLiteTableSchema(t *testing.T) { assert.EqualValues(t, "", normalizeSQLiteTableSchema(``)) assert.EqualValues(t, "CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT, "+ "repo_user_id INTEGER, repo_owner TEXT, repo_name TEXT, repo_full_name TEXT, "+ "`repo_avatar` TEXT, repo_link TEXT, repo_clone TEXT, repo_branch TEXT, "+ "repo_timeout INTEGER, repo_allow_pr BOOLEAN, repo_config_path TEXT, "+ "repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, "+ "repo_fallback BOOLEAN, UNIQUE(repo_full_name) )", normalizeSQLiteTableSchema(`CREATE TABLE repos ( repo_id INTEGER PRIMARY KEY AUTOINCREMENT ,repo_user_id INTEGER ,repo_owner TEXT, repo_name TEXT ,repo_full_name TEXT ,`+"`"+`repo_avatar`+"`"+` TEXT ,repo_link TEXT ,repo_clone TEXT ,repo_branch TEXT ,repo_timeout INTEGER ,repo_allow_pr BOOLEAN ,repo_config_path TEXT , repo_visibility TEXT, repo_counter INTEGER, repo_active BOOLEAN, repo_fallback BOOLEAN,UNIQUE(repo_full_name) )`)) } ================================================ FILE: server/store/datastore/migration/logger.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "fmt" "github.com/rs/zerolog/log" ) type xormigrateLogger struct{} func (l *xormigrateLogger) Debug(v ...any) { log.Debug().Msg(fmt.Sprint(v...)) } func (l *xormigrateLogger) Debugf(format string, v ...any) { log.Debug().Msgf(format, v...) } func (l *xormigrateLogger) Info(v ...any) { log.Info().Msg(fmt.Sprint(v...)) } func (l *xormigrateLogger) Infof(format string, v ...any) { log.Info().Msgf(format, v...) } func (l *xormigrateLogger) Warn(v ...any) { log.Warn().Msg(fmt.Sprint(v...)) } func (l *xormigrateLogger) Warnf(format string, v ...any) { log.Warn().Msgf(format, v...) } func (l *xormigrateLogger) Error(v ...any) { log.Error().Msg(fmt.Sprint(v...)) } func (l *xormigrateLogger) Errorf(format string, v ...any) { log.Error().Msgf(format, v...) } ================================================ FILE: server/store/datastore/migration/migration.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "context" "fmt" "reflect" "src.techknowlogick.com/xormigrate" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // APPEND NEW MIGRATIONS // They are executed in order and if one fails Xormigrate will try to rollback that specific one and quits. var migrationTasks = []*xormigrate.Migration{ &legacyToXormigrate, &addOrgID, &alterTableTasksUpdateColumnTaskDataType, &alterTableConfigUpdateColumnConfigDataType, &removePluginOnlyOptionFromSecretsTable, &convertToNewPipelineErrorFormat, &renameLinkToURL, &cleanRegistryPipeline, &setForgeID, &unifyColumnsTables, &alterTableRegistriesFixRequiredFields, &cronWithoutSec, &renameStartEndTime, &fixV31Registries, &removeOldMigrationsOfV1, &addOrgAgents, &addCustomLabelsToAgent, &splitTrusted, &correctPotentialCorruptOrgsUsersRelation, &gatedToRequireApproval, &removeRepoNetrcOnlyTrusted, &renameTokenFields, &setNewDefaultsForRequireApproval, &removeRepoScm, &unsanitizeOrgAndUserNames, &replaceZeroForgeIDsInOrgs, &fixForgeColumns, &addCronField, } var allBeans = []any{ new(model.Agent), new(model.Pipeline), new(model.PipelineConfig), new(model.Config), new(model.LogEntry), new(model.Perm), new(model.Step), new(model.Registry), new(model.Repo), new(model.Secret), new(model.Task), new(model.User), new(model.ServerConfig), new(model.Cron), new(model.Redirection), new(model.Forge), new(model.Workflow), new(model.Org), } // TODO: make xormigrate context aware func Migrate(_ context.Context, e *xorm.Engine, allowLong bool) error { e.SetDisableGlobalCache(true) m := xormigrate.New(e, migrationTasks) m.AllowLong(allowLong) oldExist, err := e.IsTableExist("migrations") if err != nil { return err } oldEmpty := false if oldExist { oldEmpty, err = e.IsTableEmpty("migrations") if err != nil { return err } } if !oldExist || oldEmpty { // allow new schema initialization if old migrations table is empty or it does not exist (err != nil) // schema initialization will always run if we call `InitSchema` m.InitSchema(func(_ *xorm.Engine) error { // do nothing on schema init, models are synced in any case below return nil }) } m.SetLogger(&xormigrateLogger{}) if err := m.Migrate(); err != nil { return err } e.SetDisableGlobalCache(false) if err := syncAll(e); err != nil { return fmt.Errorf("msg: %w", err) } return nil } func syncAll(sess *xorm.Engine) error { for _, bean := range allBeans { if err := sess.Sync(bean); err != nil { return fmt.Errorf("sync error '%s': %w", reflect.TypeOf(bean), err) } } return nil } ================================================ FILE: server/store/datastore/migration/migration_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package migration import ( "database/sql" "os" "strings" "testing" "time" // Blank imports to register the sql drivers. _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "xorm.io/xorm" "xorm.io/xorm/schemas" ) const ( sqliteDB = "./test-files/sqlite.db" postgresDump = "./test-files/postgres.sql" ) func testDriver() string { driver := os.Getenv("WOODPECKER_DATABASE_DRIVER") if len(driver) == 0 { return "sqlite3" } return driver } func createSQLiteDB(t *testing.T) string { tmpF, err := os.CreateTemp("./test-files", "tmp_") require.NoError(t, err) dbF, err := os.ReadFile(sqliteDB) require.NoError(t, err) require.NoError(t, os.WriteFile(tmpF.Name(), dbF, 0o644)) return tmpF.Name() } func testDB(t *testing.T, initNewDB bool) (engine *xorm.Engine, closeDB func()) { driver := testDriver() var err error closeDB = func() {} switch driver { case "sqlite3": config := ":memory:" if !initNewDB { config = createSQLiteDB(t) closeDB = func() { _ = os.Remove(config) } } engine, err = xorm.NewEngine(driver, config) require.NoError(t, err) return engine, closeDB case "mysql": config := os.Getenv("WOODPECKER_DATABASE_DATASOURCE") if !initNewDB { t.Logf("do not have dump to test against") t.SkipNow() } engine, err = xorm.NewEngine(driver, config) require.NoError(t, err) return engine, closeDB case "postgres": config := os.Getenv("WOODPECKER_DATABASE_DATASOURCE") closeDB = func() { cleanPostgresDB(t, config) } if !initNewDB { restorePostgresDump(t, config) } engine, err = xorm.NewEngine(driver, config) require.NoError(t, err) return engine, closeDB default: t.Errorf("unsupported driver: %s", driver) t.FailNow() } return engine, closeDB } // restorePostgresDump only supports dumps generated with `pg_dump --inserts`. func restorePostgresDump(t *testing.T, config string) { dump, err := os.ReadFile(postgresDump) require.NoError(t, err) db, err := sql.Open("postgres", config) require.NoError(t, err) defer db.Close() // clean dump lines := strings.Split(string(dump), "\n") newLines := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) switch { case line == "", strings.HasPrefix(line, "\\"), strings.HasPrefix(line, "--"): continue } newLines = append(newLines, line) } for _, stmt := range strings.Split(strings.Join(newLines, "\n"), ";") { if stmt == "" { continue } _, err = db.Exec(stmt) if err != nil { t.Logf("Failed to execute statement: %s", stmt[:min(len(stmt), 100)]) require.NoErrorf(t, err, "could not load postgres dump") } } } func cleanPostgresDB(t *testing.T, config string) { db, err := sql.Open("postgres", config) require.NoError(t, err) defer db.Close() // Drop and recreate the public schema // This removes all tables, indexes, constraints, sequences, etc. _, err = db.Exec(` DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public; `) require.NoError(t, err) } func TestMigrate(t *testing.T) { // init new db engine, closeDB := testDB(t, true) assert.NoError(t, Migrate(t.Context(), engine, true)) closeDB() dbType := engine.Dialect().URI().DBType if dbType == schemas.MYSQL || dbType == schemas.POSTGRES { // wait for mysql/postgres to sync ... time.Sleep(100 * time.Millisecond) } // migrate old db engine, closeDB = testDB(t, false) assert.NoError(t, Migrate(t.Context(), engine, true)) closeDB() } ================================================ FILE: server/store/datastore/migration/test-files/.gitignore ================================================ tmp_* ================================================ FILE: server/store/datastore/migration/test-files/postgres.sql ================================================ -- -- PostgreSQL database dump -- \restrict CqELvZI3DY4n4ETCf9XharkGfqppgD8kxo1FDoGUSOJMtIcV1VUigzQFXRMJZRb -- Dumped from database version 17.6 (Debian 17.6-2.pgdg13+1) -- Dumped by pg_dump version 17.6 SET statement_timeout = 0; SET lock_timeout = 0; SET idle_in_transaction_session_timeout = 0; SET transaction_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); SET check_function_bodies = false; SET xmloption = content; SET client_min_messages = warning; SET row_security = off; SET default_tablespace = ''; SET default_table_access_method = heap; -- -- Name: agents; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.agents ( id bigint NOT NULL, created bigint, updated bigint, name character varying(255), owner_id bigint, token character varying(255), last_contact bigint, platform character varying(100), backend character varying(100), capacity integer, version character varying(255), no_schedule boolean, last_work bigint, org_id bigint, custom_labels json ); ALTER TABLE public.agents OWNER TO postgres; -- -- Name: agents_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.agents_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.agents_id_seq OWNER TO postgres; -- -- Name: agents_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.agents_id_seq OWNED BY public.agents.id; -- -- Name: pipelines; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.pipelines ( id integer NOT NULL, repo_id integer, number integer, event character varying(500), status character varying(500), created integer, started integer, finished integer, commit character varying(500), branch character varying(500), ref character varying(500), refspec character varying(1000), title character varying(1000), message text, "timestamp" integer, author character varying(500), avatar character varying(1000), email character varying(500), forge_url character varying(1000), deploy character varying(500), parent integer, reviewer character varying(250), reviewed integer, sender character varying(250), changed_files text, updated bigint DEFAULT 0 NOT NULL, additional_variables json, pr_labels json, errors json, deploy_task character varying(255), is_prerelease boolean, from_fork boolean ); ALTER TABLE public.pipelines OWNER TO postgres; -- -- Name: builds_build_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.builds_build_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.builds_build_id_seq OWNER TO postgres; -- -- Name: builds_build_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.builds_build_id_seq OWNED BY public.pipelines.id; -- -- Name: configs; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.configs ( id integer NOT NULL, repo_id integer, hash character varying(250), data bytea, name text ); ALTER TABLE public.configs OWNER TO postgres; -- -- Name: config_config_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.config_config_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.config_config_id_seq OWNER TO postgres; -- -- Name: config_config_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.config_config_id_seq OWNED BY public.configs.id; -- -- Name: crons; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.crons ( id bigint NOT NULL, name character varying(255), repo_id bigint, creator_id bigint, next_exec bigint, schedule character varying(255) NOT NULL, created bigint DEFAULT 0 NOT NULL, branch character varying(255) ); ALTER TABLE public.crons OWNER TO postgres; -- -- Name: crons_i_d_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.crons_i_d_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.crons_i_d_seq OWNER TO postgres; -- -- Name: crons_i_d_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.crons_i_d_seq OWNED BY public.crons.id; -- -- Name: forges; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.forges ( id bigint NOT NULL, type character varying(250), url character varying(500), client character varying(250), client_secret character varying(250), skip_verify boolean, oauth_host character varying(250), additional_options json ); ALTER TABLE public.forges OWNER TO postgres; -- -- Name: forge_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.forge_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.forge_id_seq OWNER TO postgres; -- -- Name: forge_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.forge_id_seq OWNED BY public.forges.id; -- -- Name: log_entries; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.log_entries ( id bigint NOT NULL, step_id bigint, "time" bigint, line integer, data bytea, created bigint, type integer ); ALTER TABLE public.log_entries OWNER TO postgres; -- -- Name: log_entries_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.log_entries_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.log_entries_id_seq OWNER TO postgres; -- -- Name: log_entries_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.log_entries_id_seq OWNED BY public.log_entries.id; -- -- Name: migration; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.migration ( id character varying(255), description character varying(255) ); ALTER TABLE public.migration OWNER TO postgres; -- -- Name: orgs; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.orgs ( id bigint NOT NULL, name character varying(255), is_user boolean, private boolean, forge_id bigint ); ALTER TABLE public.orgs OWNER TO postgres; -- -- Name: orgs_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.orgs_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.orgs_id_seq OWNER TO postgres; -- -- Name: orgs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.orgs_id_seq OWNED BY public.orgs.id; -- -- Name: perms; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.perms ( user_id integer NOT NULL, repo_id integer NOT NULL, pull boolean, push boolean, admin boolean, synced integer, created bigint, updated bigint ); ALTER TABLE public.perms OWNER TO postgres; -- -- Name: pipeline_configs; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.pipeline_configs ( config_id bigint NOT NULL, pipeline_id bigint NOT NULL ); ALTER TABLE public.pipeline_configs OWNER TO postgres; -- -- Name: steps; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.steps ( id integer NOT NULL, pipeline_id integer, pid integer, ppid integer, name character varying(250), state character varying(250), error text, exit_code integer, started integer, finished integer, uuid character varying(255), failure character varying(255), type character varying(255) ); ALTER TABLE public.steps OWNER TO postgres; -- -- Name: procs_proc_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.procs_proc_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.procs_proc_id_seq OWNER TO postgres; -- -- Name: procs_proc_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.procs_proc_id_seq OWNED BY public.steps.id; -- -- Name: redirections; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.redirections ( id bigint NOT NULL, repo_id bigint, repo_full_name character varying(255) ); ALTER TABLE public.redirections OWNER TO postgres; -- -- Name: redirections_redirection_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.redirections_redirection_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.redirections_redirection_id_seq OWNER TO postgres; -- -- Name: redirections_redirection_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.redirections_redirection_id_seq OWNED BY public.redirections.id; -- -- Name: registries; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.registries ( id integer NOT NULL, repo_id integer DEFAULT 0 NOT NULL, address character varying(250) NOT NULL, username character varying(2000), password text, org_id bigint DEFAULT 0 NOT NULL ); ALTER TABLE public.registries OWNER TO postgres; -- -- Name: registry_registry_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.registry_registry_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.registry_registry_id_seq OWNER TO postgres; -- -- Name: registry_registry_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.registry_registry_id_seq OWNED BY public.registries.id; -- -- Name: repos; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.repos ( id integer NOT NULL, user_id integer, owner character varying(250), name character varying(250), full_name character varying(250), avatar character varying(500), forge_url character varying(1000), clone character varying(1000), branch character varying(500), timeout integer, private boolean, allow_pr boolean, repo_allow_push boolean, hash character varying(500), config_path character varying(500), visibility character varying(50), active boolean, forge_remote_id character varying(255), org_id bigint, cancel_previous_pipeline_events json, clone_ssh character varying(1000), pr_enabled boolean DEFAULT true, forge_id bigint, allow_deploy boolean, require_approval character varying(255), trusted json, netrc_trusted json ); ALTER TABLE public.repos OWNER TO postgres; -- -- Name: repos_repo_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.repos_repo_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.repos_repo_id_seq OWNER TO postgres; -- -- Name: repos_repo_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.repos_repo_id_seq OWNED BY public.repos.id; -- -- Name: secrets; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.secrets ( id integer NOT NULL, repo_id integer DEFAULT 0 NOT NULL, name character varying(250) NOT NULL, value bytea, images character varying(2000), events character varying(2000), org_id bigint DEFAULT 0 NOT NULL ); ALTER TABLE public.secrets OWNER TO postgres; -- -- Name: secrets_secret_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.secrets_secret_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.secrets_secret_id_seq OWNER TO postgres; -- -- Name: secrets_secret_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.secrets_secret_id_seq OWNED BY public.secrets.id; -- -- Name: server_configs; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.server_configs ( key character varying(255) NOT NULL, value character varying(255) ); ALTER TABLE public.server_configs OWNER TO postgres; -- -- Name: tasks; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.tasks ( id character varying(250) NOT NULL, data bytea, labels bytea, dependencies bytea, run_on bytea, dependencies_status json, agent_id bigint ); ALTER TABLE public.tasks OWNER TO postgres; -- -- Name: users; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.users ( id integer NOT NULL, login character varying(250), access_token text, refresh_token text, expiry integer, email character varying(500), avatar character varying(500), admin boolean, hash character varying(500), forge_remote_id character varying(255), org_id bigint, forge_id bigint ); ALTER TABLE public.users OWNER TO postgres; -- -- Name: users_user_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.users_user_id_seq AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.users_user_id_seq OWNER TO postgres; -- -- Name: users_user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.users_user_id_seq OWNED BY public.users.id; -- -- Name: workflows; Type: TABLE; Schema: public; Owner: postgres -- CREATE TABLE public.workflows ( id bigint NOT NULL, pipeline_id bigint, pid integer, name character varying(255), state character varying(255), error text, started bigint, finished bigint, agent_id bigint, platform character varying(255), environ json, axis_id integer ); ALTER TABLE public.workflows OWNER TO postgres; -- -- Name: workflows_workflow_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres -- CREATE SEQUENCE public.workflows_workflow_id_seq START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; ALTER SEQUENCE public.workflows_workflow_id_seq OWNER TO postgres; -- -- Name: workflows_workflow_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres -- ALTER SEQUENCE public.workflows_workflow_id_seq OWNED BY public.workflows.id; -- -- Name: agents id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.agents ALTER COLUMN id SET DEFAULT nextval('public.agents_id_seq'::regclass); -- -- Name: configs id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.configs ALTER COLUMN id SET DEFAULT nextval('public.config_config_id_seq'::regclass); -- -- Name: crons id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.crons ALTER COLUMN id SET DEFAULT nextval('public.crons_i_d_seq'::regclass); -- -- Name: forges id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.forges ALTER COLUMN id SET DEFAULT nextval('public.forge_id_seq'::regclass); -- -- Name: log_entries id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.log_entries ALTER COLUMN id SET DEFAULT nextval('public.log_entries_id_seq'::regclass); -- -- Name: orgs id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.orgs ALTER COLUMN id SET DEFAULT nextval('public.orgs_id_seq'::regclass); -- -- Name: pipelines id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.pipelines ALTER COLUMN id SET DEFAULT nextval('public.builds_build_id_seq'::regclass); -- -- Name: redirections id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.redirections ALTER COLUMN id SET DEFAULT nextval('public.redirections_redirection_id_seq'::regclass); -- -- Name: registries id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.registries ALTER COLUMN id SET DEFAULT nextval('public.registry_registry_id_seq'::regclass); -- -- Name: repos id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.repos ALTER COLUMN id SET DEFAULT nextval('public.repos_repo_id_seq'::regclass); -- -- Name: secrets id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.secrets ALTER COLUMN id SET DEFAULT nextval('public.secrets_secret_id_seq'::regclass); -- -- Name: steps id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.steps ALTER COLUMN id SET DEFAULT nextval('public.procs_proc_id_seq'::regclass); -- -- Name: users id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_user_id_seq'::regclass); -- -- Name: workflows id; Type: DEFAULT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.workflows ALTER COLUMN id SET DEFAULT nextval('public.workflows_workflow_id_seq'::regclass); -- -- Data for Name: agents; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.agents VALUES (1, 1641630000, 1641630000, 'agent-1', 1, 'agent_token_abc123xyz', 1641630000, 'linux', 'docker', 2, '1.0.0', false, NULL, -1, NULL); INSERT INTO public.agents VALUES (2, 1641630100, 1641630100, 'agent-2', 1, 'agent_token_def456uvw', 1641630100, 'linux', 'docker', 4, '1.0.0', false, NULL, -1, NULL); INSERT INTO public.agents VALUES (3, 1641630200, 1641630200, 'agent-3', 2, 'agent_token_ghi789rst', 1641630200, 'linux', 'kubernetes', 8, '1.0.1', false, NULL, -1, NULL); -- -- Data for Name: configs; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.configs VALUES (1, 105, 'ec8ca9529d6081e631aec26175b26ac91699395b96b9c5fc1f3af6d3aef5d3a8', '\x636c6f6e653a0a20206769743a0a20202020696d6167653a20776f6f647065636b657263692f706c7567696e2d6769743a746573740a0a73746570733a0a20205072696e743a0a20202020696d6167653a207072696e742f656e760a20202020736563726574733a205b204141414141414141414141414141414141414141414141414141205d', 'woodpecker'); -- -- Data for Name: crons; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.crons VALUES (1, 'nightly-build', 105, 1, 1641686400, '0 0 * * *', 1641630600, 'master'); -- -- Data for Name: forges; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.forges VALUES (1, 'gitea', 'http://100.114.106.50:3000', '6e9119df-a86d-4fe0-b392-fe125d7a265f', 'gto_bagkxxp5yio7npmj7uzrf5neyyalfbqykfmri3ryqfpgvlylqwsa', false, '', '{}'); -- -- Data for Name: log_entries; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.log_entries VALUES (1, 2, 0, 0, '\x537465704e616d653a20636c6f6e65', 1641630525, 0); INSERT INTO public.log_entries VALUES (2, 2, 0, 1, '\x53746570547970653a20636c6f6e65', 1641630525, 0); INSERT INTO public.log_entries VALUES (3, 2, 0, 2, '\x53746570555549443a2030314a3151344e443232594b534a31465a443654533234343357', 1641630525, 0); INSERT INTO public.log_entries VALUES (4, 2, 0, 3, '\x53746570436f6d6d616e64733a', 1641630525, 0); INSERT INTO public.log_entries VALUES (5, 2, 0, 4, '\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630525, 0); INSERT INTO public.log_entries VALUES (6, 2, 0, 5, '\x', 1641630525, 0); INSERT INTO public.log_entries VALUES (7, 2, 0, 6, '\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630525, 0); INSERT INTO public.log_entries VALUES (8, 2, 0, 7, '\x', 1641630525, 0); INSERT INTO public.log_entries VALUES (9, 3, 0, 0, '\x537465704e616d653a205072696e74', 1641630526, 0); INSERT INTO public.log_entries VALUES (10, 3, 0, 1, '\x53746570547970653a20636f6d6d616e6473', 1641630526, 0); INSERT INTO public.log_entries VALUES (11, 3, 0, 2, '\x53746570555549443a2030314a3151344e443232594b534a31465a44365739385a573047', 1641630526, 0); INSERT INTO public.log_entries VALUES (12, 3, 0, 3, '\x53746570436f6d6d616e64733a', 1641630526, 0); INSERT INTO public.log_entries VALUES (13, 3, 0, 4, '\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630526, 0); INSERT INTO public.log_entries VALUES (14, 3, 0, 5, '\x7072696e7420656e7620636f6d6d616e64', 1641630526, 0); INSERT INTO public.log_entries VALUES (15, 3, 0, 6, '\x2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d', 1641630526, 0); INSERT INTO public.log_entries VALUES (16, 3, 0, 7, '\x', 1641630526, 0); -- -- Data for Name: migration; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.migration VALUES ('SCHEMA_INIT', ''); INSERT INTO public.migration VALUES ('legacy-to-xormigrate', ''); INSERT INTO public.migration VALUES ('add-org-id', ''); INSERT INTO public.migration VALUES ('alter-table-tasks-update-type-of-task-data', ''); INSERT INTO public.migration VALUES ('alter-table-config-update-type-of-config-data', ''); INSERT INTO public.migration VALUES ('remove-plugin-only-option-from-secrets-table', ''); INSERT INTO public.migration VALUES ('convert-to-new-pipeline-error-format', ''); INSERT INTO public.migration VALUES ('rename-link-to-url', ''); INSERT INTO public.migration VALUES ('clean-registry-pipeline', ''); INSERT INTO public.migration VALUES ('set-forge-id', ''); INSERT INTO public.migration VALUES ('unify-columns-tables', ''); INSERT INTO public.migration VALUES ('alter-table-registries-fix-required-fields', ''); INSERT INTO public.migration VALUES ('correct-potential-corrupt-orgs-users-relation', ''); INSERT INTO public.migration VALUES ('gated-to-require-approval', ''); INSERT INTO public.migration VALUES ('cron-without-sec', ''); INSERT INTO public.migration VALUES ('rename-start-end-time', ''); INSERT INTO public.migration VALUES ('fix-v31-registries', ''); INSERT INTO public.migration VALUES ('remove-old-migrations-of-v1', ''); INSERT INTO public.migration VALUES ('add-org-agents', ''); INSERT INTO public.migration VALUES ('add-custom-labels-to-agent', ''); INSERT INTO public.migration VALUES ('split-trusted', ''); INSERT INTO public.migration VALUES ('remove-repo-netrc-only-trusted', ''); INSERT INTO public.migration VALUES ('rename-token-fields', ''); INSERT INTO public.migration VALUES ('set-new-defaults-for-require-approval', ''); INSERT INTO public.migration VALUES ('remove-repo-scm', ''); -- -- Data for Name: orgs; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.orgs VALUES (1, '2', false, false, 1); INSERT INTO public.orgs VALUES (2, 'test', true, false, 1); -- -- Data for Name: perms; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.perms VALUES (1, 1, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 2, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 3, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 4, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 5, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 6, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 7, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 8, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 9, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 10, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 11, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 12, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 13, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 14, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 15, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 16, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 17, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 18, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 19, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 20, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 21, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 22, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 23, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 24, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 25, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 26, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 27, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 28, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 29, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 30, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 31, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 32, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 33, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 34, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 35, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 36, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 37, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 38, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 39, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 40, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 41, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 42, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 43, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 44, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 45, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 46, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 47, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 48, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 49, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 50, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 51, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 52, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 53, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 54, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 55, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 56, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 57, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 58, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 59, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 60, true, true, true, 1641626844, NULL, NULL); INSERT INTO public.perms VALUES (1, 115, true, true, true, 1641630451, NULL, NULL); INSERT INTO public.perms VALUES (1, 105, true, true, true, 1641630452, NULL, NULL); -- -- Data for Name: pipeline_configs; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.pipeline_configs VALUES (1, 1); -- -- Data for Name: pipelines; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.pipelines VALUES (1, 105, 1, 'push', 'failure', 1641630525, 1641630525, 1641630527, '24bf205107cea48b92bc6444e18e40d21733a594', 'master', 'refs/heads/master', '', '', '„.woodpecker.yml“ hinzufügen\n', 1641630525, 'test', 'http://10.40.8.5:3000/avatars/d6c72f5d7e2a070b52e1194969df2cfe', 'test@test.test', 'http://10.40.8.5:3000/2/settings/compare/3fee083df05667d525878b5fcbd4eaf2a121c559...24bf205107cea48b92bc6444e18e40d21733a594', '', 0, '', 0, 'test', '[".woodpecker.yml"]\n', 0, NULL, NULL, NULL, NULL, NULL, NULL); -- -- Data for Name: redirections; Type: TABLE DATA; Schema: public; Owner: postgres -- -- -- Data for Name: registries; Type: TABLE DATA; Schema: public; Owner: postgres -- -- -- Data for Name: repos; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.repos VALUES (115, 1, '2', 'testCIservices', '2/testCIservices', 'http://10.40.8.5:3000/avatars/c81e728d9d4c2f636f067f89cc14862c', 'http://10.40.8.5:3000/2/testCIservices', 'http://10.40.8.5:3000/2/testCIservices.git', 'master', 60, false, true, true, 'FOUXTSNL2GXK7JP2SQQJVWVAS6J4E4SGIQYPAHEJBIFPVR46LLDA====', '.woodpecker.yml', 'public', true, NULL, 1, NULL, NULL, true, 1, NULL, 'forks', '{"network":false,"volumes":false,"security":false}', NULL); INSERT INTO public.repos VALUES (105, 1, '2', 'settings', '2/settings', 'http://10.40.8.5:3000/avatars/c81e728d9d4c2f636f067f89cc14862c', 'http://10.40.8.5:3000/2/settings', 'http://10.40.8.5:3000/2/settings.git', 'master', 60, false, true, true, '3OQA7X5CNGPTILDYLQSJFDML6U2W7UUFBPPP2G2LRBG3WETAYZLA====', '.woodpecker.yml', 'public', true, NULL, 1, NULL, NULL, true, 1, NULL, 'forks', '{"network":false,"volumes":false,"security":false}', NULL); -- -- Data for Name: secrets; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.secrets VALUES (1, 105, 'wow', '\x74657374', 'null\n', '["push","tag","deployment","pull_request"]\n', 0); INSERT INTO public.secrets VALUES (2, 105, 'n', '\x6e', 'null\n', '["deployment"]\n', 0); INSERT INTO public.secrets VALUES (3, 105, 'abc', '\x656466', 'null\n', '["push"]\n', 0); INSERT INTO public.secrets VALUES (4, 105, 'quak', '\x66647361', 'null\n', '["pull_request"]\n', 0); -- -- Data for Name: server_configs; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.server_configs VALUES ('signature-private-key', '1fe3b71c87d7f89fa878306028cf08d66020ef6cafc2af90d05c40ebd03eee3c93189d2a3c46fe5292afc33e9237615ed595ee3d588dce431d5f6848b6a9bf77'); INSERT INTO public.server_configs VALUES ('jwt-secret', 'GKQDHRJXNN5ONIMOHJUMYDBR4IYIH46M6E5HOXX3Q2KEVZ35GM5Q===='); -- -- Data for Name: steps; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.steps VALUES (2, 1, 2, 1, 'git', 'success', '', 0, 1641630525, 1641630527, NULL, NULL, NULL); INSERT INTO public.steps VALUES (3, 1, 3, 1, 'Print', 'skipped', '', 0, 0, 0, NULL, NULL, NULL); -- -- Data for Name: tasks; Type: TABLE DATA; Schema: public; Owner: postgres -- -- -- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.users VALUES (1, 'test', 'eyJhbGciOiJSUzI1NiIsImtpZCI6IldmbUJ1c2Q0RndUVWRmMjc2NHowUWlEYlJ3TnRBcU5pNVlXS1U1c2k0eEEiLCJ0eXAiOiJKV1QifQ.eyJnbnQiOjEsInR0IjowLCJleHAiOjE2NDE2MzQxMjcsImlhdCI6MTY0MTYzMDUyN30.Fu0wUP-08NpPjq737y6HOeyKN_-_SE4iOZr5yrH7S8Jrz8nIuNKfU7AvlypeMSJ7wo8e3cSTadbSH1polZuFv-Nb1AqWDDXeuXudm61BkF96sTslbSHd0nF7cOy6hqCfIAfQLQpqZTJZ4E26oOSSJxPfOOntOWhlEejRl5F-flXAoYAQLegHxdn9IfYJeM1eanZqF4k6dT9hthFp9v4fmUjODPPfHip_iS7ckPonP1E4-8KeNkU3O-lIS1fgrsbCDA8531FXIGB0U7cSur7H0picKGL6WSzAErPGntlNlQWYB5JedDtLN9Ionxy1Y9LKQON76XYL4gM1Ji98RCEXggVqd7TW0B1fGV-Jve2hU3fKaDyQywsCJp36mpnVaqb5eiTssncHixAwZE0C4yh_XsTd-WoVhsbqlEuDfPTjrtAK94mSzHJTcO3fbtE9L-MoPevQIPM7Yog0i2Xn1oPUCDXVXsV2yJriBiI_r2xbG0nz5Bwn8KAFZ0dNGJ7T9urqKaKMh9guE4jgYLIpRpod_Fd13_GAK0ebgF2CZJdjJT7eEGhzzcg4uFpFdIXL2kNgVN1D6YLMPw3HhVg7_MIfASbJgpcppFhYa4Fk-OpchL5-e_mMyeWogvaJA2wSpyY1f5zJlBnFuIyk_OdV0TwQ3b_TjutehsiibT9WRpOK8h8', 'eyJhbGciOiJSUzI1NiIsImtpZCI6IldmbUJ1c2Q0RndUVWRmMjc2NHowUWlEYlJ3TnRBcU5pNVlXS1U1c2k0eEEiLCJ0eXAiOiJKV1QifQ.eyJnbnQiOjEsInR0IjoxLCJleHAiOjE2NDQyNTg1MjcsImlhdCI6MTY0MTYzMDUyN30.iVtIGQ6VTgRI8L3xFD_YNvVBGZ6kdFb3ERdyOCIHC_CHhOEpZxVGawMGnNNooqbNdmOqJQ0RLJyiAirEKdxSVrtWvqub6uVMjjpeBylE1sAFymCGNJQf77dKvgPHW3QY5FvOSoOoNcRU2g99Bx8sbZhiI12GnNOB-abazrzICpOUikiTdb2ri3w_TNF2Ibrn-itSa1yuhmTrVpqXt_CT4MEfteiDmgjyqonmk-J_BqbcriF3DKAvrXNK1VKVU7xODcFSIRizlgA2kDmnpMT3Oo-Z1I37TFIGAuDOTgcceOPa7rXg_Mfd_jhL7bSH1BI4RsK0rgde3NaCQlU2n7yVOYGbJCSsSWwSAi-gCjjuTTPnQWe3ep3IWrB73_7tKG2_x7YxZ1nQCSFKouA5rZH4g6yoV8wdJh8_bX2Z64-MJBUl8E7JGM2urA5GY1abo0GZ6ZuQi2JS5WnG1iTL9pFlmOoTpN1DKtNE2PUE90GJwi0qGeACif9uJBXQPDAgKk7fbUxKYQobc6ko2CJ1isoRjbi8-GsJ9lhw7tXno5zfAvN3eps9SYgmIRNh0t_vx-LMBezSTSEcTJpv-7Ap6F10GD3E9KmGcYrOMvdtaYgkWFXO6rh49uElUVid-C1tNVpKjnj7ewUosQo9MHSn-d5l1df0rJSueXcaUMSqRSrEzqQ', 1641634127, 'test@test.test', 'http://10.40.8.5:3000/avatars/d6c72f5d7e2a070b52e1194969df2cfe', true, 'OBW2OF5QH3NMCYJ44VU5B5YEQ5LHZLTFW2FDSAJ4R4JVZ4HWSNVQ====', NULL, 2, 1); INSERT INTO public.users VALUES (2, 'user2', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsImlhdCI6MTY0MTYzMDUyNywiZXhwIjoxNjQxNjM0MTI3fQ.example_token_user2', 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMiIsImlhdCI6MTY0MTYzMDUyNywiZXhwIjoxNjQ0MjU4NTI3fQ.example_secret_user2', 1641634127, 'user2@test.test', 'http://10.40.8.5:3000/avatars/default2', false, 'HASH2EXAMPLEHASH2EXAMPLEHASH2EXAMPLEHASH2EXAMPLE====', NULL, 2, 1); -- -- Data for Name: workflows; Type: TABLE DATA; Schema: public; Owner: postgres -- INSERT INTO public.workflows VALUES (1, 1, 1, 'woodpecker', 'failure', 'Error response from daemon: manifest for woodpeckerci/plugin-git:test not found: manifest unknown: manifest unknown', 1641630525, 1641630527, 0, '', '{}', NULL); -- -- Name: agents_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.agents_id_seq', 3, true); -- -- Name: builds_build_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.builds_build_id_seq', 1, true); -- -- Name: config_config_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.config_config_id_seq', 1, true); -- -- Name: crons_i_d_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.crons_i_d_seq', 1, false); -- -- Name: forge_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.forge_id_seq', 1, true); -- -- Name: log_entries_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.log_entries_id_seq', 1, false); -- -- Name: orgs_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.orgs_id_seq', 2, true); -- -- Name: procs_proc_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.procs_proc_id_seq', 3, true); -- -- Name: redirections_redirection_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.redirections_redirection_id_seq', 1, false); -- -- Name: registry_registry_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.registry_registry_id_seq', 1, false); -- -- Name: repos_repo_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.repos_repo_id_seq', 122, true); -- -- Name: secrets_secret_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.secrets_secret_id_seq', 4, true); -- -- Name: users_user_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.users_user_id_seq', 2, true); -- -- Name: workflows_workflow_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- SELECT pg_catalog.setval('public.workflows_workflow_id_seq', 1, true); -- -- Name: agents agents_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.agents ADD CONSTRAINT agents_pkey PRIMARY KEY (id); -- -- Name: pipelines builds_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.pipelines ADD CONSTRAINT builds_pkey PRIMARY KEY (id); -- -- Name: configs config_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.configs ADD CONSTRAINT config_pkey PRIMARY KEY (id); -- -- Name: crons crons_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.crons ADD CONSTRAINT crons_pkey PRIMARY KEY (id); -- -- Name: forges forge_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.forges ADD CONSTRAINT forge_pkey PRIMARY KEY (id); -- -- Name: log_entries log_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.log_entries ADD CONSTRAINT log_entries_pkey PRIMARY KEY (id); -- -- Name: orgs orgs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.orgs ADD CONSTRAINT orgs_pkey PRIMARY KEY (id); -- -- Name: perms perms_perm_user_id_perm_repo_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.perms ADD CONSTRAINT perms_perm_user_id_perm_repo_id_key UNIQUE (user_id, repo_id); -- -- Name: steps procs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.steps ADD CONSTRAINT procs_pkey PRIMARY KEY (id); -- -- Name: redirections redirections_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.redirections ADD CONSTRAINT redirections_pkey PRIMARY KEY (id); -- -- Name: registries registry_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.registries ADD CONSTRAINT registry_pkey PRIMARY KEY (id); -- -- Name: repos repos_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.repos ADD CONSTRAINT repos_pkey PRIMARY KEY (id); -- -- Name: secrets secrets_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.secrets ADD CONSTRAINT secrets_pkey PRIMARY KEY (id); -- -- Name: server_configs server_config_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.server_configs ADD CONSTRAINT server_config_pkey PRIMARY KEY (key); -- -- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.tasks ADD CONSTRAINT tasks_pkey PRIMARY KEY (id); -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.users ADD CONSTRAINT users_pkey PRIMARY KEY (id); -- -- Name: workflows workflows_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- ALTER TABLE ONLY public.workflows ADD CONSTRAINT workflows_pkey PRIMARY KEY (id); -- -- Name: IDX_agents_org_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_agents_org_id" ON public.agents USING btree (org_id); -- -- Name: IDX_crons_creator_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_crons_creator_id" ON public.crons USING btree (creator_id); -- -- Name: IDX_crons_name; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_crons_name" ON public.crons USING btree (name); -- -- Name: IDX_crons_repo_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_crons_repo_id" ON public.crons USING btree (repo_id); -- -- Name: IDX_log_entries_step_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_log_entries_step_id" ON public.log_entries USING btree (step_id); -- -- Name: IDX_perms_perm_repo_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_perms_perm_repo_id" ON public.perms USING btree (repo_id); -- -- Name: IDX_perms_perm_user_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_perms_perm_user_id" ON public.perms USING btree (user_id); -- -- Name: IDX_pipelines_pipeline_author; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_pipelines_pipeline_author" ON public.pipelines USING btree (author); -- -- Name: IDX_pipelines_pipeline_repo_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_pipelines_pipeline_repo_id" ON public.pipelines USING btree (repo_id); -- -- Name: IDX_pipelines_pipeline_status; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_pipelines_pipeline_status" ON public.pipelines USING btree (status); -- -- Name: IDX_registries_address; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_registries_address" ON public.registries USING btree (address); -- -- Name: IDX_registries_org_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_registries_org_id" ON public.registries USING btree (org_id); -- -- Name: IDX_registries_repo_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_registries_repo_id" ON public.registries USING btree (repo_id); -- -- Name: IDX_repos_org_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_repos_org_id" ON public.repos USING btree (org_id); -- -- Name: IDX_repos_user_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_repos_user_id" ON public.repos USING btree (user_id); -- -- Name: IDX_secrets_secret_name; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_secrets_secret_name" ON public.secrets USING btree (name); -- -- Name: IDX_secrets_secret_org_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_secrets_secret_org_id" ON public.secrets USING btree (org_id); -- -- Name: IDX_secrets_secret_repo_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_secrets_secret_repo_id" ON public.secrets USING btree (repo_id); -- -- Name: IDX_steps_pipeline_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_steps_pipeline_id" ON public.steps USING btree (pipeline_id); -- -- Name: IDX_steps_uuid; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_steps_uuid" ON public.steps USING btree (uuid); -- -- Name: IDX_workflows_pipeline_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE INDEX "IDX_workflows_pipeline_id" ON public.workflows USING btree (pipeline_id); -- -- Name: UQE_config_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_config_s" ON public.configs USING btree (repo_id, hash, name); -- -- Name: UQE_crons_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_crons_s" ON public.crons USING btree (name, repo_id); -- -- Name: UQE_orgs_name; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_orgs_name" ON public.orgs USING btree (name); -- -- Name: UQE_pipeline_config_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_pipeline_config_s" ON public.pipeline_configs USING btree (config_id, pipeline_id); -- -- Name: UQE_pipelines_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_pipelines_s" ON public.pipelines USING btree (repo_id, number); -- -- Name: UQE_redirections_repo_full_name; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_redirections_repo_full_name" ON public.redirections USING btree (repo_full_name); -- -- Name: UQE_registries_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_registries_s" ON public.registries USING btree (org_id, repo_id, address); -- -- Name: UQE_repos_full_name; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_repos_full_name" ON public.repos USING btree (full_name); -- -- Name: UQE_repos_name; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_repos_name" ON public.repos USING btree (owner, name); -- -- Name: UQE_secrets_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_secrets_s" ON public.secrets USING btree (org_id, repo_id, name); -- -- Name: UQE_steps_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_steps_s" ON public.steps USING btree (pipeline_id, pid); -- -- Name: UQE_tasks_task_id; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_tasks_task_id" ON public.tasks USING btree (id); -- -- Name: UQE_users_hash; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_users_hash" ON public.users USING btree (hash); -- -- Name: UQE_users_login; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_users_login" ON public.users USING btree (login); -- -- Name: UQE_workflows_s; Type: INDEX; Schema: public; Owner: postgres -- CREATE UNIQUE INDEX "UQE_workflows_s" ON public.workflows USING btree (pipeline_id, pid); -- -- PostgreSQL database dump complete -- \unrestrict CqELvZI3DY4n4ETCf9XharkGfqppgD8kxo1FDoGUSOJMtIcV1VUigzQFXRMJZRb ================================================ FILE: server/store/datastore/org.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "fmt" "strings" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) OrgCreate(org *model.Org) error { return s.orgCreate(org, s.engine.NewSession()) } func (s storage) orgCreate(org *model.Org, sess *xorm.Session) error { if org.Name == "" { return fmt.Errorf("org name is empty") } return wrapInsert(sess.Insert(org)) } func (s storage) OrgGet(id int64) (*model.Org, error) { org := new(model.Org) return org, wrapGet(s.engine.ID(id).Get(org)) } func (s storage) OrgUpdate(org *model.Org) error { return s.orgUpdate(s.engine.NewSession(), org) } func (s storage) orgUpdate(sess *xorm.Session, org *model.Org) error { // update _, err := sess.ID(org.ID).AllCols().Update(org) return err } func (s storage) OrgDelete(id int64) error { return s.orgDelete(s.engine.NewSession(), id) } func (s storage) orgDelete(sess *xorm.Session, id int64) error { if _, err := sess.Where("org_id = ?", id).Delete(new(model.Secret)); err != nil { return err } var repos []*model.Repo if err := sess.Where("org_id = ?", id).Find(&repos); err != nil { return err } for _, repo := range repos { if err := s.deleteRepo(sess, repo); err != nil { return err } } return wrapDelete(sess.ID(id).Delete(new(model.Org))) } func (s storage) OrgFindByName(name string, forgeID int64) (*model.Org, error) { return s.orgFindByName(s.engine.NewSession(), name, forgeID) } func (s storage) orgFindByName(sess *xorm.Session, name string, forgeID int64) (*model.Org, error) { // sanitize org := new(model.Org) return org, wrapGet(sess.Where("LOWER(name) = ?", strings.ToLower(name)).And("forge_id = ?", forgeID).Get(org)) } func (s storage) OrgRepoList(org *model.Org, p *model.ListOptions) ([]*model.Repo, error) { var repos []*model.Repo return repos, s.paginate(p).OrderBy("id").Where("org_id = ?", org.ID).Find(&repos) } func (s storage) OrgList(p *model.ListOptions) ([]*model.Org, error) { var orgs []*model.Org return orgs, s.paginate(p).Where("is_user = ?", false).OrderBy("id").Find(&orgs) } ================================================ FILE: server/store/datastore/org_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestOrgCRUD(t *testing.T) { store, closer := newTestStore(t, new(model.Org), new(model.Repo), new(model.Secret), new(model.Config), new(model.Perm), new(model.Registry), new(model.Redirection), new(model.Pipeline)) defer closer() org1 := &model.Org{ Name: "someAwesomeOrg", ForgeID: 1, IsUser: false, Private: true, } // create first org to play with assert.NoError(t, store.OrgCreate(org1)) assert.EqualValues(t, "someAwesomeOrg", org1.Name) // don't allow the same name in different casing assert.Error(t, store.OrgCreate(&model.Org{ID: org1.ID, Name: "someawesomeorg"})) // retrieve it orgOne, err := store.OrgGet(org1.ID) assert.NoError(t, err) assert.EqualValues(t, org1, orgOne) // change name assert.NoError(t, store.OrgUpdate(&model.Org{ID: org1.ID, ForgeID: 1, Name: "RenamedOrg"})) // find updated org by name orgOne, err = store.OrgFindByName("RenamedOrg", 1) assert.NoError(t, err) assert.NotEqualValues(t, org1, orgOne) assert.EqualValues(t, org1.ID, orgOne.ID) assert.EqualValues(t, false, orgOne.IsUser) assert.EqualValues(t, false, orgOne.Private) assert.EqualValues(t, "RenamedOrg", orgOne.Name) // create two more orgs and repos someUser := &model.Org{Name: "some_other_u", IsUser: true} assert.NoError(t, store.OrgCreate(someUser)) assert.NoError(t, store.OrgCreate(&model.Org{Name: "some_other_org"})) assert.NoError(t, store.CreateRepo(&model.Repo{ForgeRemoteID: "a", UserID: 1, Owner: "some_other_u", Name: "abc", FullName: "some_other_u/abc", OrgID: someUser.ID})) assert.NoError(t, store.CreateRepo(&model.Repo{ForgeRemoteID: "b", UserID: 1, Owner: "some_other_u", Name: "xyz", FullName: "some_other_u/xyz", OrgID: someUser.ID})) assert.NoError(t, store.CreateRepo(&model.Repo{ForgeRemoteID: "c", UserID: 1, Owner: "renamedorg", Name: "567", FullName: "renamedorg/567", OrgID: orgOne.ID})) assert.Error(t, store.OrgCreate(&model.Org{Name: ""}), "expect to fail if name is empty") // get all repos for a specific org repos, err := store.OrgRepoList(someUser, &model.ListOptions{All: true}) assert.NoError(t, err) assert.Len(t, repos, 2) // delete an org and check if it's gone assert.NoError(t, store.OrgDelete(org1.ID)) assert.Error(t, store.OrgDelete(org1.ID)) } ================================================ FILE: server/store/datastore/permission.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "fmt" "time" "xorm.io/builder" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) PermFind(user *model.User, repo *model.Repo) (*model.Perm, error) { perm := new(model.Perm) return perm, wrapGet(s.engine. Where(builder.Eq{"user_id": user.ID, "repo_id": repo.ID}). Get(perm)) } func (s storage) PermUpsert(perm *model.Perm) error { sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } if err := s.permUpsert(sess, perm); err != nil { return err } return sess.Commit() } func (s storage) permUpsert(sess *xorm.Session, perm *model.Perm) error { if perm.RepoID == 0 { return fmt.Errorf("could not determine repo for permission: %v", perm) } if perm.UserID == 0 { return fmt.Errorf("could not determine user for permission: %v", perm) } exist, err := sess.Where(userIDAndRepoIDCond(perm)).Exist(new(model.Perm)) if err != nil { return err } if exist { perm.Updated = time.Now().Unix() _, err = sess.Where(userIDAndRepoIDCond(perm)).AllCols().Update(perm) } else { // insert will set auto created ID back to perm object perm.Created = time.Now().Unix() perm.Updated = perm.Created err = wrapInsert(sess.Insert(perm)) } return err } // userPushOrAdminCondition return condition where user must have push or admin rights // if used make sure to have permission table ("perms") joined. func userPushOrAdminCondition(userID int64) builder.Cond { return builder.Eq{"perms.user_id": userID}. And(builder.Eq{"perms.push": true}. Or(builder.Eq{"perms.admin": true})) } func userIDAndRepoIDCond(perm *model.Perm) builder.Cond { return builder.Eq{"user_id": perm.UserID, "repo_id": perm.RepoID} } // PermPrune deletes all permission rows for a user // where the repo_id is NOT IN the provided keepRepoIDs list. If keepRepoIDs // is empty, all permissions for the user are deleted. func (s storage) PermPrune(userID int64, keepRepoIDs []int64) error { if len(keepRepoIDs) == 0 { _, err := s.engine.Where(builder.Eq{"user_id": userID}).Delete(new(model.Perm)) return err } _, err := s.engine.Where(builder.Eq{"user_id": userID}). And(builder.NotIn("repo_id", keepRepoIDs)). Delete(new(model.Perm)) return err } ================================================ FILE: server/store/datastore/permission_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestPermFind(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User)) defer closer() user := &model.User{ID: 1} repo := &model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", ForgeRemoteID: "1", } assert.NoError(t, store.CreateRepo(repo)) err := store.PermUpsert( &model.Perm{ UserID: user.ID, RepoID: repo.ID, Pull: true, Push: false, Admin: false, }, ) assert.NoError(t, err) perm, err := store.PermFind(user, repo) assert.NoError(t, err) assert.True(t, perm.Pull) assert.False(t, perm.Push) assert.False(t, perm.Admin) } func TestPermUpsert(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User)) defer closer() user := &model.User{ID: 1} repo := &model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", ForgeRemoteID: "1", } assert.NoError(t, store.CreateRepo(repo)) err := store.PermUpsert( &model.Perm{ UserID: user.ID, RepoID: repo.ID, Pull: true, Push: false, Admin: false, }, ) assert.NoError(t, err) perm, err := store.PermFind(user, repo) assert.NoError(t, err) assert.True(t, perm.Pull) assert.False(t, perm.Push) assert.False(t, perm.Admin) // // this will attempt to replace the existing permissions // using the insert or replace logic. // err = store.PermUpsert( &model.Perm{ UserID: user.ID, RepoID: repo.ID, Pull: true, Push: true, Admin: true, }, ) assert.NoError(t, err) perm, err = store.PermFind(user, repo) assert.NoError(t, err) assert.True(t, perm.Pull) assert.True(t, perm.Push) assert.True(t, perm.Admin) } func TestPermPruneDeleteAll(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User)) defer closer() user := &model.User{ID: 1} repo1 := &model.Repo{ UserID: 1, FullName: "woodpecker-ci/woodpecker1", Owner: "woodpecker-ci", Name: "repo1", ForgeRemoteID: "101", } repo2 := &model.Repo{ UserID: 1, FullName: "woodpecker-ci/woodpecker2", Owner: "woodpecker", Name: "repo2", ForgeRemoteID: "102", } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) assert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo1.ID, Pull: true})) assert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo2.ID, Pull: true})) _, err := store.PermFind(user, repo1) assert.NoError(t, err) _, err = store.PermFind(user, repo2) assert.NoError(t, err) assert.NoError(t, store.PermPrune(user.ID, []int64{})) _, err = store.PermFind(user, repo1) assert.ErrorIs(t, err, types.ErrRecordNotExist) _, err = store.PermFind(user, repo2) assert.ErrorIs(t, err, types.ErrRecordNotExist) } func TestPermPruneKeepOne(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.Perm), new(model.User)) defer closer() user := &model.User{ID: 1} repo1 := &model.Repo{ UserID: 1, FullName: "woodpecker-ci/woodpecker1", Owner: "woodpecker-ci", Name: "repo1", ForgeRemoteID: "101", } repo2 := &model.Repo{ UserID: 1, FullName: "woodpecker-ci/woodpecker2", Owner: "woodpecker", Name: "repo2", ForgeRemoteID: "102", } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) assert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo1.ID, Pull: true})) assert.NoError(t, store.PermUpsert(&model.Perm{UserID: user.ID, RepoID: repo2.ID, Pull: true})) _, err := store.PermFind(user, repo1) assert.NoError(t, err) _, err = store.PermFind(user, repo2) assert.NoError(t, err) // Prune everything EXCEPT repo2 assert.NoError(t, store.PermPrune(user.ID, []int64{repo2.ID})) _, err = store.PermFind(user, repo1) assert.ErrorIs(t, err, types.ErrRecordNotExist) _, err = store.PermFind(user, repo2) assert.NoError(t, err) } ================================================ FILE: server/store/datastore/pipeline.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "context" "strings" "time" "github.com/cenkalti/backoff/v5" "xorm.io/builder" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) GetPipeline(id int64) (*model.Pipeline, error) { pipeline := &model.Pipeline{} return pipeline, wrapGet(s.engine.ID(id).Get(pipeline)) } func (s storage) GetPipelineNumber(repo *model.Repo, num int64) (*model.Pipeline, error) { pipeline := new(model.Pipeline) return pipeline, wrapGet(s.engine.Where( builder.Eq{"repo_id": repo.ID, "number": num}, ).Get(pipeline)) } func (s storage) GetPipelineBadge(repo *model.Repo, branch string, events []model.WebhookEvent) (*model.Pipeline, error) { pipeline := new(model.Pipeline) return pipeline, wrapGet(s.engine. Desc("number"). Where(builder.Eq{"repo_id": repo.ID, "branch": branch}). Where(builder.In("event", events)). Where(builder.Neq{"status": model.StatusBlocked}). Get(pipeline)) } func (s storage) GetPipelineLastByBranch(repo *model.Repo, branch string) (*model.Pipeline, error) { pipeline := new(model.Pipeline) return pipeline, wrapGet(s.engine. Desc("number"). Where(builder.Eq{"repo_id": repo.ID, "branch": branch, "event": model.EventPush}). Get(pipeline)) } func (s storage) GetPipelineLastBefore(repo *model.Repo, branch string, num int64) (*model.Pipeline, error) { pipeline := new(model.Pipeline) return pipeline, wrapGet(s.engine. Desc("number"). Where(builder.Lt{"id": num}. And(builder.Eq{"repo_id": repo.ID, "branch": branch})). Get(pipeline)) } func (s storage) GetPipelineList(repo *model.Repo, p *model.ListOptions, f *model.PipelineFilter) ([]*model.Pipeline, error) { pipelines := make([]*model.Pipeline, 0, 16) cond := builder.NewCond().And(builder.Eq{"repo_id": repo.ID}) if f != nil { if f.After != 0 { cond = cond.And(builder.Gt{"created": f.After}) } if f.Before != 0 { cond = cond.And(builder.Lt{"created": f.Before}) } if f.Branch != "" { cond = cond.And(builder.Eq{"branch": f.Branch}) } if f.Status != "" { cond = cond.And(builder.Eq{"status": f.Status}) } if len(f.Events) != 0 { cond = cond.And(builder.In("event", f.Events)) } if f.RefContains != "" { cond = cond.And(builder.Like{"ref", f.RefContains}) } } return pipelines, s.paginate(p).Where(cond). Desc("number"). Find(&pipelines) } // GetRepoLatestPipelines get the latest pipeline for each repo. func (s storage) GetRepoLatestPipelines(repoIDs []int64) ([]*model.Pipeline, error) { pipelines := make([]*model.Pipeline, 0, len(repoIDs)) pipelineIDs := make([]int64, 0, len(repoIDs)) if err := s.engine.Select("MAX(id) AS id"). Table("pipelines"). Where(builder.In("repo_id", repoIDs)). GroupBy("repo_id"). Find(&pipelineIDs); err != nil { return nil, err } return pipelines, s.engine.Where(builder.In("id", pipelineIDs)).Find(&pipelines) } // GetActivePipelineList get all pipelines that are pending, running or blocked. func (s storage) GetActivePipelineList(repo *model.Repo) ([]*model.Pipeline, error) { pipelines := make([]*model.Pipeline, 0) query := s.engine. Where("repo_id = ?", repo.ID). In("status", model.StatusPending, model.StatusRunning, model.StatusBlocked). Desc("number") return pipelines, query.Find(&pipelines) } func (s storage) GetPipelineCount() (int64, error) { return s.engine.Count(new(model.Pipeline)) } // CreatePipeline creates a new pipeline with retry logic for unique constraint errors. func (s storage) CreatePipeline(pipeline *model.Pipeline, stepList ...*model.Step) error { // Maximum number of retries const maxRetries = 3 // Create backoff configuration exponentialBackoff := backoff.NewExponentialBackOff() // Execute with backoff retry _, err := backoff.Retry(context.Background(), func() (struct{}, error) { sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return struct{}{}, err } repoExist, err := sess.Where("id = ?", pipeline.RepoID).Exist(&model.Repo{}) if err != nil { return struct{}{}, err } if !repoExist { return struct{}{}, ErrorRepoNotExist{RepoID: pipeline.RepoID} } // calc pipeline number var number int64 if _, err := sess.Select("MAX(number)"). Table(new(model.Pipeline)). Where("repo_id = ?", pipeline.RepoID). Get(&number); err != nil { return struct{}{}, err } pipeline.Number = number + 1 pipeline.Created = time.Now().UTC().Unix() // only Insert set auto created ID back to object if err := wrapInsert(sess.Insert(pipeline)); err != nil { if isUniqueConstraintError(err) { return struct{}{}, err } return struct{}{}, backoff.Permanent(err) } for i := range stepList { stepList[i].PipelineID = pipeline.ID // only Insert set auto created ID back to object if err := wrapInsert(sess.Insert(stepList[i])); err != nil { if isUniqueConstraintError(err) { return struct{}{}, err } return struct{}{}, backoff.Permanent(err) } } return struct{}{}, sess.Commit() }, backoff.WithBackOff(exponentialBackoff), backoff.WithMaxTries(maxRetries)) return err } // isUniqueConstraintError checks if an error is a unique constraint violation error. func isUniqueConstraintError(err error) bool { if err == nil { return false } errStr := err.Error() // Check for common unique constraint error patterns across different databases return strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "Duplicate entry") || strings.Contains(errStr, "UNIQUE constraint failed") || strings.Contains(errStr, "unique constraint") || strings.Contains(errStr, "UNIQUE violation") } func (s storage) UpdatePipeline(pipeline *model.Pipeline) error { _, err := s.engine.ID(pipeline.ID).AllCols().Update(pipeline) return err } func (s storage) DeletePipeline(pipeline *model.Pipeline) error { return s.deletePipeline(s.engine.NewSession(), pipeline.ID) } func (s storage) deletePipeline(sess *xorm.Session, pipelineID int64) error { if err := s.workflowsDelete(sess, pipelineID); err != nil { return err } var confIDs []int64 if err := sess.Table(new(model.PipelineConfig)).Select("config_id").Where("pipeline_id = ?", pipelineID).Find(&confIDs); err != nil { return err } for _, confID := range confIDs { exist, err := sess.Where(builder.Eq{"config_id": confID}.And(builder.Neq{"pipeline_id": pipelineID})).Exist(new(model.PipelineConfig)) if err != nil { return err } if !exist { // this config is only used for this pipeline. so delete it if _, err := sess.Where(builder.Eq{"id": confID}).Delete(new(model.Config)); err != nil { return err } } } if _, err := sess.Where("pipeline_id = ?", pipelineID).Delete(new(model.PipelineConfig)); err != nil { return err } return wrapDelete(sess.ID(pipelineID).Delete(new(model.Pipeline))) } ================================================ FILE: server/store/datastore/pipeline_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestPipelines(t *testing.T) { repo := &model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } store, closer := newTestStore(t, new(model.Repo), new(model.Step), new(model.Pipeline)) defer closer() assert.NoError(t, store.CreateRepo(repo)) // Fail when the repo is not existing pipeline := model.Pipeline{ RepoID: 100, Status: model.StatusSuccess, } err := store.CreatePipeline(&pipeline) assert.Error(t, err) count, err := store.GetPipelineCount() assert.NoError(t, err) assert.Zero(t, count) // add pipeline pipeline = model.Pipeline{ RepoID: repo.ID, Status: model.StatusSuccess, Commit: "85f8c029b902ed9400bc600bac301a0aadb144ac", Event: model.EventPush, Branch: "some-branch", } err = store.CreatePipeline(&pipeline) assert.NoError(t, err) assert.NotZero(t, pipeline.ID) assert.EqualValues(t, 1, pipeline.Number) assert.Equal(t, "85f8c029b902ed9400bc600bac301a0aadb144ac", pipeline.Commit) count, err = store.GetPipelineCount() assert.NoError(t, err) assert.NotZero(t, count) GetPipeline, err := store.GetPipeline(pipeline.ID) assert.NoError(t, err) assert.Equal(t, pipeline.ID, GetPipeline.ID) assert.Equal(t, pipeline.RepoID, GetPipeline.RepoID) assert.Equal(t, pipeline.Status, GetPipeline.Status) // update pipeline pipeline.Status = model.StatusRunning require.NoError(t, store.UpdatePipeline(&pipeline)) GetPipeline, err1 := store.GetPipeline(pipeline.ID) require.NoError(t, err1) assert.Equal(t, pipeline.ID, GetPipeline.ID) assert.Equal(t, pipeline.RepoID, GetPipeline.RepoID) assert.Equal(t, pipeline.Status, GetPipeline.Status) assert.Equal(t, pipeline.Number, GetPipeline.Number) pipeline2 := &model.Pipeline{ RepoID: repo.ID, Status: model.StatusPending, Event: model.EventPush, Branch: "main", } require.NoError(t, store.CreatePipeline(pipeline2, []*model.Step{}...)) GetPipeline, err3 := store.GetPipelineNumber(&model.Repo{ID: 1}, pipeline2.Number) require.NoError(t, err3) assert.Equal(t, pipeline2.ID, GetPipeline.ID) assert.Equal(t, pipeline2.RepoID, GetPipeline.RepoID) assert.Equal(t, pipeline2.Number, GetPipeline.Number) GetPipeline, err4 := store.GetPipelineLastByBranch(&model.Repo{ID: repo.ID}, pipeline2.Branch) require.NoError(t, err4) assert.Equal(t, pipeline2.ID, GetPipeline.ID) assert.Equal(t, pipeline2.RepoID, GetPipeline.RepoID) assert.Equal(t, pipeline2.Number, GetPipeline.Number) assert.Equal(t, pipeline2.Status, GetPipeline.Status) pipeline3 := &model.Pipeline{ RepoID: repo.ID, Status: model.StatusRunning, Branch: "main", Event: model.EventPull, Commit: "85f8c029b902ed9400bc600bac301a0aadb144aa", ForgeURL: "example.com/id3", } require.NoError(t, store.CreatePipeline(pipeline3)) GetPipeline, err5 := store.GetPipelineLastBefore(&model.Repo{ID: 1}, pipeline3.Branch, pipeline3.ID) require.NoError(t, err5) assert.EqualValues(t, pipeline2, GetPipeline) } func TestPipelineListFilter(t *testing.T) { repo := &model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } store, closer := newTestStore(t, new(model.Repo), new(model.Step), new(model.Pipeline)) defer closer() assert.NoError(t, store.CreateRepo(repo)) pipeline1 := &model.Pipeline{ RepoID: repo.ID, Status: model.StatusFailure, Event: model.EventCron, Ref: "refs/heads/some-branch", Branch: "some-branch", } pipeline2 := &model.Pipeline{ RepoID: repo.ID, Status: model.StatusSuccess, Event: model.EventPull, Ref: "refs/pull/32", Branch: "main", } err := store.CreatePipeline(pipeline1, []*model.Step{}...) assert.NoError(t, err) time.Sleep(1 * time.Second) before := time.Now().Unix() err = store.CreatePipeline(pipeline2, []*model.Step{}...) assert.NoError(t, err) pipelines, err := store.GetPipelineList(&model.Repo{ID: 1}, &model.ListOptions{Page: 1, PerPage: 50}, nil) assert.NoError(t, err) assert.Len(t, (pipelines), 2) assert.Equal(t, pipeline2.ID, pipelines[0].ID) assert.Equal(t, pipeline2.RepoID, pipelines[0].RepoID) assert.Equal(t, pipeline2.Status, pipelines[0].Status) pipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{ Branch: "main", }) assert.NoError(t, err) assert.Len(t, pipelines, 1) assert.Equal(t, pipeline2.ID, pipelines[0].ID) pipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{ Events: []model.WebhookEvent{model.EventCron}, }) assert.NoError(t, err) assert.Len(t, pipelines, 1) assert.Equal(t, pipeline1.ID, pipelines[0].ID) pipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{ Events: []model.WebhookEvent{model.EventCron, model.EventPull}, RefContains: "32", }) assert.NoError(t, err) assert.Len(t, (pipelines), 1) assert.Equal(t, pipeline2.ID, pipelines[0].ID) pipelines, err3 := store.GetPipelineList(&model.Repo{ID: 1}, &model.ListOptions{Page: 1, PerPage: 50}, &model.PipelineFilter{Before: before}) assert.NoError(t, err3) assert.Len(t, pipelines, 1) assert.Equal(t, pipeline1.ID, pipelines[0].ID) assert.Equal(t, pipeline1.RepoID, pipelines[0].RepoID) pipelines, err = store.GetPipelineList(&model.Repo{ID: 1}, nil, &model.PipelineFilter{ Status: model.StatusSuccess, }) assert.NoError(t, err) assert.Len(t, pipelines, 1) assert.Equal(t, pipeline2.ID, pipelines[0].ID) assert.Equal(t, model.StatusSuccess, pipelines[0].Status) } func TestPipelineIncrement(t *testing.T) { store, closer := newTestStore(t, new(model.Pipeline), new(model.Repo)) defer closer() assert.NoError(t, store.CreateRepo(&model.Repo{ID: 1, Owner: "1", Name: "1", FullName: "1/1", ForgeRemoteID: "1"})) assert.NoError(t, store.CreateRepo(&model.Repo{ID: 2, Owner: "2", Name: "2", FullName: "2/2", ForgeRemoteID: "2"})) pipelineA := &model.Pipeline{RepoID: 1} require.NoError(t, store.CreatePipeline(pipelineA)) assert.EqualValues(t, 1, pipelineA.Number) pipelineB := &model.Pipeline{RepoID: 1} assert.NoError(t, store.CreatePipeline(pipelineB)) assert.EqualValues(t, 2, pipelineB.Number) pipelineC := &model.Pipeline{RepoID: 2} assert.NoError(t, store.CreatePipeline(pipelineC)) assert.EqualValues(t, 1, pipelineC.Number) } func TestDeletePipeline(t *testing.T) { store, closer := newTestStore(t, new(model.Pipeline), new(model.Repo), new(model.Workflow), new(model.Step), new(model.LogEntry), new(model.PipelineConfig), new(model.Config)) defer closer() err := wrapInsert(store.engine.Insert( &model.Pipeline{ ID: 2, Number: 2, RepoID: 7, }, &model.Pipeline{ ID: 5, Number: 3, RepoID: 7, }, &model.Pipeline{ ID: 8, Number: 4, RepoID: 7, }, &model.Config{ ID: 23, Hash: "1234", Name: "test", RepoID: 7, }, &model.Config{ ID: 25, Hash: "6789", Name: "test", RepoID: 7, }, &model.PipelineConfig{ PipelineID: 2, ConfigID: 23, }, &model.PipelineConfig{ PipelineID: 5, ConfigID: 23, }, &model.PipelineConfig{ PipelineID: 8, ConfigID: 25, }, )) assert.NoError(t, err) // delete non existing pipeline assert.ErrorIs(t, types.ErrRecordNotExist, store.DeletePipeline(&model.Pipeline{ID: 1})) // delete pipeline with shares config assert.NoError(t, store.DeletePipeline(&model.Pipeline{ID: 2})) count, err := store.engine.Count(new(model.Config)) assert.NoError(t, err) assert.EqualValues(t, 2, count) // delete pipeline with unique config assert.NoError(t, store.DeletePipeline(&model.Pipeline{ID: 8})) count, err = store.engine.Count(new(model.Config)) assert.NoError(t, err) assert.EqualValues(t, 1, count) } ================================================ FILE: server/store/datastore/redirection.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "xorm.io/builder" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) getRedirection(e *xorm.Session, fullName string) (*model.Redirection, error) { repo := new(model.Redirection) return repo, wrapGet(e.Where("repo_full_name = ?", fullName).Get(repo)) } func (s storage) CreateRedirection(redirect *model.Redirection) error { sess := s.engine.NewSession() defer sess.Close() return s.createRedirection(sess, redirect) } func (s storage) createRedirection(e *xorm.Session, redirect *model.Redirection) error { // only Insert set auto created ID back to object return wrapInsert(e.Insert(redirect)) } func (s storage) HasRedirectionForRepo(repoID int64, fullName string) (bool, error) { return s.engine.Where( builder.Eq{"repo_id": repoID, "repo_full_name": fullName}, ).Exist(new(model.Redirection)) } ================================================ FILE: server/store/datastore/redirection_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestCreateRedirection(t *testing.T) { store, closer := newTestStore(t, new(model.Redirection)) defer closer() redirection := &model.Redirection{ RepoID: 1, FullName: "foo/bar", } assert.NoError(t, store.CreateRedirection(redirection)) } func TestHasRedirectionForRepo(t *testing.T) { store, closer := newTestStore(t, new(model.Redirection)) defer closer() redirection := &model.Redirection{ RepoID: 1, FullName: "foo/bar", } assert.NoError(t, store.CreateRedirection(redirection)) has, err := store.HasRedirectionForRepo(1, "foo/bar") assert.NoError(t, err) assert.True(t, has) has, err = store.HasRedirectionForRepo(1, "foo/baz") assert.NoError(t, err) assert.False(t, has) } ================================================ FILE: server/store/datastore/registry.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "xorm.io/builder" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const orderRegistriesBy = "id" func (s storage) RegistryFind(repo *model.Repo, addr string) (*model.Registry, error) { reg := new(model.Registry) return reg, wrapGet(s.engine.Where( builder.Eq{"repo_id": repo.ID, "address": addr}, ).Get(reg)) } func (s storage) RegistryList(repo *model.Repo, includeGlobalAndOrg bool, p *model.ListOptions) ([]*model.Registry, error) { var regs []*model.Registry var cond builder.Cond = builder.Eq{"repo_id": repo.ID} if includeGlobalAndOrg { cond = cond.Or(builder.Eq{"org_id": repo.OrgID}). Or(builder.And(builder.Eq{"org_id": 0}, builder.Eq{"repo_id": 0})) } return regs, s.paginate(p).Where(cond).OrderBy(orderRegistriesBy).Find(®s) } func (s storage) RegistryListAll() ([]*model.Registry, error) { var registries []*model.Registry return registries, s.engine.Find(®istries) } func (s storage) RegistryCreate(registry *model.Registry) error { // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(registry)) } func (s storage) RegistryUpdate(registry *model.Registry) error { _, err := s.engine.ID(registry.ID).AllCols().Update(registry) return err } func (s storage) RegistryDelete(registry *model.Registry) error { return wrapDelete(s.engine.ID(registry.ID).Delete(new(model.Registry))) } func (s storage) OrgRegistryFind(orgID int64, name string) (*model.Registry, error) { registry := new(model.Registry) return registry, wrapGet(s.engine.Where( builder.Eq{"org_id": orgID, "address": name}, ).Get(registry)) } func (s storage) OrgRegistryList(orgID int64, p *model.ListOptions) ([]*model.Registry, error) { registries := make([]*model.Registry, 0) return registries, s.paginate(p).Where("org_id = ?", orgID).OrderBy(orderRegistriesBy).Find(®istries) } func (s storage) GlobalRegistryFind(name string) (*model.Registry, error) { registry := new(model.Registry) return registry, wrapGet(s.engine.Where( builder.Eq{"org_id": 0, "repo_id": 0, "address": name}, ).Get(registry)) } func (s storage) GlobalRegistryList(p *model.ListOptions) ([]*model.Registry, error) { registries := make([]*model.Registry, 0) return registries, s.paginate(p).Where( builder.Eq{"org_id": 0, "repo_id": 0}, ).OrderBy(orderRegistriesBy).Find(®istries) } ================================================ FILE: server/store/datastore/registry_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestRegistryFind(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() err := store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "index.docker.io", Username: "foo", Password: "bar", }) assert.NoError(t, err) registry, err := store.RegistryFind(&model.Repo{ID: 1}, "index.docker.io") assert.NoError(t, err) assert.EqualValues(t, 1, registry.RepoID) assert.Equal(t, "index.docker.io", registry.Address) assert.Equal(t, "foo", registry.Username) assert.Equal(t, "bar", registry.Password) } func TestRegistryList(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() assert.NoError(t, store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "index.docker.io", Username: "foo", Password: "bar", })) assert.NoError(t, store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "foo.docker.io", Username: "foo", Password: "bar", })) list, err := store.RegistryList(&model.Repo{ID: 1}, false, &model.ListOptions{Page: 1, PerPage: 50}) assert.NoError(t, err) assert.Len(t, list, 2) } func TestRegistryUpdate(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() registry := &model.Registry{ RepoID: 1, Address: "index.docker.io", Username: "foo", Password: "bar", } assert.NoError(t, store.RegistryCreate(registry)) registry.Password = "qux" assert.NoError(t, store.RegistryUpdate(registry)) updated, err := store.RegistryFind(&model.Repo{ID: 1}, "index.docker.io") assert.NoError(t, err) assert.Equal(t, "qux", updated.Password) } func TestRegistryIndexes(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() assert.NoError(t, store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "index.docker.io", Username: "foo", Password: "bar", })) // fail due to duplicate addr assert.Error(t, store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "index.docker.io", Username: "baz", Password: "qux", })) } func TestRegistryDelete(t *testing.T) { store, closer := newTestStore(t, new(model.Registry), new(model.Repo)) defer closer() reg1 := &model.Registry{ RepoID: 1, Address: "index.docker.io", Username: "foo", Password: "bar", } require.NoError(t, store.RegistryCreate(reg1)) assert.NoError(t, store.RegistryDelete(reg1)) assert.ErrorIs(t, store.RegistryDelete(reg1), types.ErrRecordNotExist) } func createTestRegistries(t *testing.T, store *storage) { assert.NoError(t, store.RegistryCreate(&model.Registry{ OrgID: 12, Address: "my.regsitry.local", })) assert.NoError(t, store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "private.registry.local", })) assert.NoError(t, store.RegistryCreate(&model.Registry{ RepoID: 1, Address: "very-private.registry.local", })) assert.NoError(t, store.RegistryCreate(&model.Registry{ Address: "index.docker.io", })) } func TestOrgRegistryFind(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() err := store.RegistryCreate(&model.Registry{ OrgID: 12, Address: "my.regsitry.local", Username: "username", Password: "password", }) assert.NoError(t, err) registry, err := store.OrgRegistryFind(12, "my.regsitry.local") assert.NoError(t, err) assert.EqualValues(t, 12, registry.OrgID) assert.Equal(t, "my.regsitry.local", registry.Address) assert.Equal(t, "username", registry.Username) assert.Equal(t, "password", registry.Password) } func TestOrgRegistryList(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() createTestRegistries(t, store) list, err := store.OrgRegistryList(12, &model.ListOptions{All: true}) assert.NoError(t, err) require.Len(t, list, 1) assert.True(t, list[0].IsOrganization()) } func TestGlobalRegistryFind(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() err := store.RegistryCreate(&model.Registry{ Address: "my.regsitry.local", Username: "username", Password: "password", }) assert.NoError(t, err) registry, err := store.GlobalRegistryFind("my.regsitry.local") assert.NoError(t, err) assert.Equal(t, "my.regsitry.local", registry.Address) assert.Equal(t, "username", registry.Username) assert.Equal(t, "password", registry.Password) } func TestGlobalRegistryList(t *testing.T) { store, closer := newTestStore(t, new(model.Registry)) defer closer() createTestRegistries(t, store) list, err := store.GlobalRegistryList(&model.ListOptions{All: true}) assert.NoError(t, err) assert.Len(t, list, 1) assert.True(t, list[0].IsGlobal()) } ================================================ FILE: server/store/datastore/repo.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "errors" "fmt" "strings" "xorm.io/builder" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (s storage) GetRepo(id int64) (*model.Repo, error) { repo := new(model.Repo) return repo, wrapGet(s.engine.ID(id).Get(repo)) } func (s storage) GetRepoForgeID(forgeID int64, remoteID model.ForgeRemoteID) (*model.Repo, error) { sess := s.engine.NewSession() defer sess.Close() return s.getRepoForgeID(sess, forgeID, remoteID) } func (s storage) getRepoForgeID(e *xorm.Session, forgeID int64, remoteID model.ForgeRemoteID) (*model.Repo, error) { repo := new(model.Repo) return repo, wrapGet(e.Where("forge_id = ? AND forge_remote_id = ?", forgeID, remoteID).Get(repo)) } func (s storage) GetRepoNameFallback(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) { sess := s.engine.NewSession() defer sess.Close() return s.getRepoNameFallback(sess, forgeID, remoteID, fullName) } func (s storage) getRepoNameFallback(e *xorm.Session, forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) { repo, err := s.getRepoForgeID(e, forgeID, remoteID) if errors.Is(err, types.ErrRecordNotExist) { return s.getRepoName(e, fullName) } return repo, err } func (s storage) GetRepoName(fullName string) (*model.Repo, error) { sess := s.engine.NewSession() defer sess.Close() repo, err := s.getRepoName(sess, fullName) if errors.Is(err, types.ErrRecordNotExist) { // the repository does not exist, so look for a redirection redirect, err := s.getRedirection(sess, fullName) if err != nil { return nil, err } return s.GetRepo(redirect.RepoID) } return repo, err } func (s storage) getRepoName(e *xorm.Session, fullName string) (*model.Repo, error) { repo := new(model.Repo) return repo, wrapGet(e.Where("LOWER(full_name) = ?", strings.ToLower(fullName)).Get(repo)) } func (s storage) GetRepoCount() (int64, error) { return s.engine.Where(builder.Eq{"active": true}).Count(new(model.Repo)) } func (s storage) CreateRepo(repo *model.Repo) error { switch { case repo.Name == "": return fmt.Errorf("repo name is empty") case repo.Owner == "": return fmt.Errorf("repo owner is empty") case repo.FullName == "": return fmt.Errorf("repo full name is empty") } // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(repo)) } func (s storage) UpdateRepo(repo *model.Repo) error { _, err := s.engine.ID(repo.ID).AllCols().Update(repo) return err } func (s storage) DeleteRepo(repo *model.Repo) error { return s.deleteRepo(s.engine.NewSession(), repo) } func (s storage) deleteRepo(sess *xorm.Session, repo *model.Repo) error { const batchSize = perPage if _, err := sess.Where("repo_id = ?", repo.ID).Delete(new(model.Config)); err != nil { return err } if _, err := sess.Where("repo_id = ?", repo.ID).Delete(new(model.Perm)); err != nil { return err } if _, err := sess.Where("repo_id = ?", repo.ID).Delete(new(model.Registry)); err != nil { return err } if _, err := sess.Where("repo_id = ?", repo.ID).Delete(new(model.Secret)); err != nil { return err } if _, err := sess.Where("repo_id = ?", repo.ID).Delete(new(model.Redirection)); err != nil { return err } // delete related pipelines for startPipelines := 0; ; startPipelines += batchSize { pipelineIDs := make([]int64, 0, batchSize) if err := sess.Limit(batchSize, startPipelines).Table("pipelines").Cols("id").Where("repo_id = ?", repo.ID).Find(&pipelineIDs); err != nil { return err } if len(pipelineIDs) == 0 { break } for i := range pipelineIDs { if err := s.deletePipeline(sess, pipelineIDs[i]); err != nil { return err } } } return wrapDelete(sess.ID(repo.ID).Delete(new(model.Repo))) } // RepoList list all repos where permissions for specific user are stored // TODO: paginate func (s storage) RepoList(user *model.User, owned, active bool, f *model.RepoFilter) ([]*model.Repo, error) { repos := make([]*model.Repo, 0) sess := s.engine.Table("repos"). Join("INNER", "perms", "perms.repo_id = repos.id"). Where("perms.user_id = ?", user.ID) if owned { sess = sess.And(builder.Eq{"perms.push": true}.Or(builder.Eq{"perms.admin": true})) } if active { sess = sess.And(builder.Eq{"repos.active": true}) } if f != nil && f.Name != "" { sess = sess.And(builder.Eq{"repos.name": f.Name}) } return repos, sess. Asc("full_name"). Find(&repos) } // RepoListAll list all repos. func (s storage) RepoListAll(active bool, p *model.ListOptions) ([]*model.Repo, error) { repos := make([]*model.Repo, 0) sess := s.paginate(p).Table("repos") if active { sess = sess.And(builder.Eq{"repos.active": true}) } return repos, sess. Asc("full_name"). Find(&repos) } ================================================ FILE: server/store/datastore/repo_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestCreateRepo(t *testing.T) { store, closer := newTestStore(t, new(model.Repo)) defer closer() repo := model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } err := store.CreateRepo(&repo) assert.NoError(t, err) assert.NotZero(t, repo.ID) err2 := store.UpdateRepo(&repo) getRepo, err3 := store.GetRepo(repo.ID) assert.NoError(t, err2) assert.NoError(t, err3) assert.Equal(t, repo.ID, getRepo.ID) // test that repo has name/owner/fullname assert.Error(t, store.CreateRepo(&model.Repo{ UserID: 1, FullName: "bradrydzewski/", Owner: "bradrydzewski", Name: "", })) assert.Error(t, store.CreateRepo(&model.Repo{ UserID: 1, FullName: "/test", Owner: "", Name: "test", })) assert.Error(t, store.CreateRepo(&model.Repo{ UserID: 1, FullName: "", Owner: "bradrydzewski", Name: "test", })) // test unique name repo2 := model.Repo{ UserID: 2, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } err2 = store.CreateRepo(&repo2) assert.Error(t, err2) } func TestGetRepo(t *testing.T) { store, closer := newTestStore(t, new(model.Repo)) defer closer() repo := model.Repo{ UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } assert.NoError(t, store.CreateRepo(&repo)) getrepo, err := store.GetRepo(repo.ID) assert.NoError(t, err) assert.Equal(t, repo.ID, getrepo.ID) assert.Equal(t, repo.UserID, getrepo.UserID) assert.Equal(t, repo.Owner, getrepo.Owner) assert.Equal(t, repo.Name, getrepo.Name) } func TestGetRepoName(t *testing.T) { store, closer := newTestStore(t, new(model.Repo)) defer closer() repo := model.Repo{ UserID: 1, FullName: "bradrydzewski/TEST", Owner: "bradrydzewski", Name: "TEST", } assert.NoError(t, store.CreateRepo(&repo)) getrepo, err := store.GetRepoName(repo.FullName) assert.NoError(t, err) assert.Equal(t, repo.ID, getrepo.ID) assert.Equal(t, repo.UserID, getrepo.UserID) assert.Equal(t, repo.Owner, getrepo.Owner) assert.Equal(t, repo.Name, getrepo.Name) } func TestRepoList(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Org)) defer closer() user := &model.User{ Login: "joe", Email: "foo@bar.com", AccessToken: "e42080dddf012c718e476da161d21ad5", } assert.NoError(t, store.CreateUser(user)) repo1 := &model.Repo{ Owner: "bradrydzewski", Name: "test", FullName: "bradrydzewski/test", ForgeRemoteID: "1", } repo2 := &model.Repo{ Owner: "test", Name: "test", FullName: "test/test", ForgeRemoteID: "2", } repo3 := &model.Repo{ Owner: "octocat", Name: "hello-world", FullName: "octocat/hello-world", ForgeRemoteID: "3", } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) assert.NoError(t, store.CreateRepo(repo3)) for _, perm := range []*model.Perm{ {UserID: user.ID, RepoID: repo1.ID}, {UserID: user.ID, RepoID: repo2.ID}, } { assert.NoError(t, store.PermUpsert(perm)) } tests := []struct { name string filter *model.RepoFilter expected []string }{ { name: "no filter", filter: nil, expected: []string{"test", "test"}, }, { name: "filter by name 'test'", filter: &model.RepoFilter{ Name: "test", }, expected: []string{"test", "test"}, }, { name: "filter by name 'hello-world'", filter: &model.RepoFilter{ Name: "hello-world", }, expected: []string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { repos, err := store.RepoList(user, false, false, tt.filter) assert.NoError(t, err) assert.Len(t, repos, len(tt.expected)) names := []string{} for _, repo := range repos { names = append(names, repo.Name) } assert.ElementsMatch(t, tt.expected, names) }) } } func TestOwnedRepoList(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Org)) defer closer() user := &model.User{ Login: "joe", Email: "foo@bar.com", AccessToken: "e42080dddf012c718e476da161d21ad5", } assert.NoError(t, store.CreateUser(user)) repo1 := &model.Repo{ Owner: "bradrydzewski", Name: "test", FullName: "bradrydzewski/test", ForgeRemoteID: "1", } repo2 := &model.Repo{ Owner: "test", Name: "test", FullName: "test/test", ForgeRemoteID: "2", } repo3 := &model.Repo{ Owner: "octocat", Name: "hello-world", FullName: "octocat/hello-world", ForgeRemoteID: "3", } repo4 := &model.Repo{ Owner: "demo", Name: "demo", FullName: "demo/demo", ForgeRemoteID: "4", } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) assert.NoError(t, store.CreateRepo(repo3)) assert.NoError(t, store.CreateRepo(repo4)) for _, perm := range []*model.Perm{ {UserID: user.ID, RepoID: repo1.ID, Push: true, Admin: false}, {UserID: user.ID, RepoID: repo2.ID, Push: false, Admin: true}, {UserID: user.ID, RepoID: repo3.ID}, {UserID: user.ID, RepoID: repo4.ID}, } { assert.NoError(t, store.PermUpsert(perm)) } repos, err := store.RepoList(user, true, false, nil) assert.NoError(t, err) assert.Len(t, repos, 2) assert.Equal(t, repo1.ID, repos[0].ID) assert.Equal(t, repo2.ID, repos[1].ID) } func TestRepoCount(t *testing.T) { store, closer := newTestStore(t, new(model.Repo)) defer closer() repo1 := &model.Repo{ ForgeRemoteID: "A", Owner: "bradrydzewski", Name: "test", FullName: "bradrydzewski/test", IsActive: true, } repo2 := &model.Repo{ ForgeRemoteID: "B", Owner: "test", Name: "test", FullName: "test/test", IsActive: true, } repo3 := &model.Repo{ ForgeRemoteID: "C", Owner: "test", Name: "test-ui", FullName: "test/test-ui", IsActive: false, } assert.NoError(t, store.CreateRepo(repo1)) assert.NoError(t, store.CreateRepo(repo2)) assert.NoError(t, store.CreateRepo(repo3)) count, err := store.GetRepoCount() assert.NoError(t, err) assert.EqualValues(t, 2, count) } func TestRepoCrud(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.PipelineConfig), new(model.LogEntry), new(model.Step), new(model.Secret), new(model.Registry), new(model.Config), new(model.Redirection), new(model.Workflow)) defer closer() repo := model.Repo{ ForgeID: 1, ForgeRemoteID: "bradrydzewskitest", UserID: 1, FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } assert.NoError(t, store.CreateRepo(&repo)) pipeline := model.Pipeline{ RepoID: repo.ID, } step := model.Step{ Name: "a step", } assert.NoError(t, store.CreatePipeline(&pipeline, &step)) // create unrelated repoUnrelated := model.Repo{ ForgeRemoteID: "xx", ForgeID: 1, UserID: 2, FullName: "x/x", Owner: "x", Name: "x", } assert.NoError(t, store.CreateRepo(&repoUnrelated)) pipelineUnrelated := model.Pipeline{ RepoID: repoUnrelated.ID, } stepUnrelated := model.Step{ UUID: "44c0de71-a6be-41c9-b860-e3716d1dfcef", Name: "a unrelated step", } assert.NoError(t, store.CreatePipeline(&pipelineUnrelated, &stepUnrelated)) _, err := store.GetRepo(repo.ID) assert.NoError(t, err) assert.NoError(t, store.DeleteRepo(&repo)) _, err = store.GetRepo(repo.ID) assert.Error(t, err) stepCount, err := store.engine.Count(new(model.Step)) assert.NoError(t, err) assert.EqualValues(t, 1, stepCount) pipelineCount, err := store.engine.Count(new(model.Pipeline)) assert.NoError(t, err) assert.EqualValues(t, 1, pipelineCount) } func TestRepoRedirection(t *testing.T) { store, closer := newTestStore(t, new(model.Repo), new(model.Redirection)) defer closer() repo := model.Repo{ UserID: 1, ForgeID: 1, ForgeRemoteID: "1", FullName: "bradrydzewski/test", Owner: "bradrydzewski", Name: "test", } assert.NoError(t, store.CreateRepo(&repo)) repoUpdated := model.Repo{ ID: repo.ID, ForgeRemoteID: "1", FullName: "bradrydzewski/test-renamed", Owner: "bradrydzewski", Name: "test-renamed", } assert.NoError(t, store.UpdateRepo(&repoUpdated)) assert.NoError(t, store.CreateRedirection(&model.Redirection{ RepoID: repo.ID, FullName: repo.FullName, })) // test redirection from old repo name repoFromStore, err := store.GetRepoNameFallback(0, "1", "bradrydzewski/test") assert.NoError(t, err) assert.Equal(t, repoFromStore.FullName, repoUpdated.FullName) // test getting repo without forge ID (use name fallback) repo = model.Repo{ UserID: 1, ForgeRemoteID: "bradrydzewski/test-no-forge-id", FullName: "bradrydzewski/test-no-forge-id", Owner: "bradrydzewski", Name: "test-no-forge-id", } assert.NoError(t, store.CreateRepo(&repo)) repoFromStore, err = store.GetRepoNameFallback(0, "", "bradrydzewski/test-no-forge-id") assert.NoError(t, err) assert.Equal(t, repoFromStore.FullName, repo.FullName) } ================================================ FILE: server/store/datastore/secret.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "xorm.io/builder" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) const orderSecretsBy = "name" func (s storage) SecretFind(repo *model.Repo, name string) (*model.Secret, error) { secret := new(model.Secret) return secret, wrapGet(s.engine.Where( builder.Eq{"repo_id": repo.ID, "name": name}, ).Get(secret)) } func (s storage) SecretList(repo *model.Repo, includeGlobalAndOrgSecrets bool, p *model.ListOptions) ([]*model.Secret, error) { var secrets []*model.Secret var cond builder.Cond = builder.Eq{"repo_id": repo.ID} if includeGlobalAndOrgSecrets { cond = cond.Or(builder.Eq{"org_id": repo.OrgID}). Or(builder.And(builder.Eq{"org_id": 0}, builder.Eq{"repo_id": 0})) } return secrets, s.paginate(p).Where(cond).OrderBy(orderSecretsBy).Find(&secrets) } func (s storage) SecretListAll() ([]*model.Secret, error) { var secrets []*model.Secret return secrets, s.engine.Find(&secrets) } func (s storage) SecretCreate(secret *model.Secret) error { // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(secret)) } func (s storage) SecretUpdate(secret *model.Secret) error { _, err := s.engine.ID(secret.ID).AllCols().Update(secret) return err } func (s storage) SecretDelete(secret *model.Secret) error { return wrapDelete(s.engine.ID(secret.ID).Delete(new(model.Secret))) } func (s storage) OrgSecretFind(orgID int64, name string) (*model.Secret, error) { secret := new(model.Secret) return secret, wrapGet(s.engine.Where( builder.Eq{"org_id": orgID, "name": name}, ).Get(secret)) } func (s storage) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) { secrets := make([]*model.Secret, 0) return secrets, s.paginate(p).Where("org_id = ?", orgID).OrderBy(orderSecretsBy).Find(&secrets) } func (s storage) GlobalSecretFind(name string) (*model.Secret, error) { secret := new(model.Secret) return secret, wrapGet(s.engine.Where( builder.Eq{"org_id": 0, "repo_id": 0, "name": name}, ).Get(secret)) } func (s storage) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) { secrets := make([]*model.Secret, 0) return secrets, s.paginate(p).Where( builder.Eq{"org_id": 0, "repo_id": 0}, ).OrderBy(orderSecretsBy).Find(&secrets) } ================================================ FILE: server/store/datastore/secret_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestSecretFind(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() err := store.SecretCreate(&model.Secret{ RepoID: 1, Name: "password", Value: "correct-horse-battery-staple", Images: []string{"golang", "node"}, Events: []model.WebhookEvent{"push", "tag"}, }) assert.NoError(t, err) secret, err := store.SecretFind(&model.Repo{ID: 1}, "password") assert.NoError(t, err) assert.EqualValues(t, 1, secret.RepoID) assert.Equal(t, "password", secret.Name) assert.Equal(t, "correct-horse-battery-staple", secret.Value) assert.Equal(t, model.EventPush, secret.Events[0]) assert.Equal(t, model.EventTag, secret.Events[1]) assert.Equal(t, "golang", secret.Images[0]) assert.Equal(t, "node", secret.Images[1]) } func TestSecretList(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() createTestSecrets(t, store) list, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, false, &model.ListOptions{Page: 1, PerPage: 50}) assert.NoError(t, err) assert.Len(t, list, 2) } func TestSecretListAll(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() createTestSecrets(t, store) list, err := store.SecretListAll() assert.NoError(t, err) assert.Len(t, list, 4) } func TestSecretPipelineList(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() createTestSecrets(t, store) list, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, true, &model.ListOptions{Page: 1, PerPage: 50}) assert.NoError(t, err) assert.Len(t, list, 4) } func TestSecretUpdate(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() secret := &model.Secret{ RepoID: 1, Name: "foo", Value: "baz", } assert.NoError(t, store.SecretCreate(secret)) secret.Value = "qux" assert.EqualValues(t, 1, secret.ID) assert.NoError(t, store.SecretUpdate(secret)) updated, err := store.SecretFind(&model.Repo{ID: 1}, "foo") assert.NoError(t, err) assert.Equal(t, "qux", updated.Value) } func TestSecretDelete(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() secret := &model.Secret{ RepoID: 1, Name: "foo", Value: "baz", } assert.NoError(t, store.SecretCreate(secret)) assert.NoError(t, store.SecretDelete(secret)) _, err := store.SecretFind(&model.Repo{ID: 1}, "foo") assert.Error(t, err) } func TestSecretIndexes(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() assert.NoError(t, store.SecretCreate(&model.Secret{ RepoID: 1, Name: "foo", Value: "bar", })) // fail due to duplicate name assert.Error(t, store.SecretCreate(&model.Secret{ RepoID: 1, Name: "foo", Value: "baz", })) } func createTestSecrets(t *testing.T, store *storage) { assert.NoError(t, store.SecretCreate(&model.Secret{ OrgID: 12, Name: "usr", Value: "sec", })) assert.NoError(t, store.SecretCreate(&model.Secret{ RepoID: 1, Name: "foo", Value: "bar", })) assert.NoError(t, store.SecretCreate(&model.Secret{ RepoID: 1, Name: "baz", Value: "qux", })) assert.NoError(t, store.SecretCreate(&model.Secret{ Name: "global", Value: "val", })) } func TestOrgSecretFind(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() err := store.SecretCreate(&model.Secret{ OrgID: 12, Name: "password", Value: "correct-horse-battery-staple", Images: []string{"golang", "node"}, Events: []model.WebhookEvent{"push", "tag"}, }) assert.NoError(t, err) secret, err := store.OrgSecretFind(12, "password") assert.NoError(t, err) assert.EqualValues(t, 12, secret.OrgID) assert.Equal(t, "password", secret.Name) assert.Equal(t, "correct-horse-battery-staple", secret.Value) assert.Equal(t, model.EventPush, secret.Events[0]) assert.Equal(t, model.EventTag, secret.Events[1]) assert.Equal(t, "golang", secret.Images[0]) assert.Equal(t, "node", secret.Images[1]) } func TestOrgSecretList(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() createTestSecrets(t, store) list, err := store.OrgSecretList(12, &model.ListOptions{All: true}) assert.NoError(t, err) assert.Len(t, list, 1) assert.True(t, list[0].IsOrganization()) } func TestGlobalSecretFind(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() err := store.SecretCreate(&model.Secret{ Name: "password", Value: "correct-horse-battery-staple", Images: []string{"golang", "node"}, Events: []model.WebhookEvent{"push", "tag"}, }) assert.NoError(t, err) secret, err := store.GlobalSecretFind("password") assert.NoError(t, err) assert.Equal(t, "password", secret.Name) assert.Equal(t, "correct-horse-battery-staple", secret.Value) assert.Equal(t, model.EventPush, secret.Events[0]) assert.Equal(t, model.EventTag, secret.Events[1]) assert.Equal(t, "golang", secret.Images[0]) assert.Equal(t, "node", secret.Images[1]) } func TestGlobalSecretList(t *testing.T) { store, closer := newTestStore(t, new(model.Secret)) defer closer() createTestSecrets(t, store) list, err := store.GlobalSecretList(&model.ListOptions{All: true}) assert.NoError(t, err) assert.Len(t, list, 1) assert.True(t, list[0].IsGlobal()) } ================================================ FILE: server/store/datastore/server_config.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import "go.woodpecker-ci.org/woodpecker/v3/server/model" func (s storage) ServerConfigGet(key string) (string, error) { config := new(model.ServerConfig) err := wrapGet(s.engine.ID(key).Get(config)) if err != nil { return "", err } return config.Value, nil } func (s storage) ServerConfigSet(key, value string) error { config := &model.ServerConfig{ Key: key, } sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } count, err := sess.Count(config) if err != nil { return err } config.Value = value if count == 0 { err = wrapInsert(sess.Insert(config)) } else { _, err = sess.Where("`key` = ?", config.Key).Cols("value").Update(config) } if err != nil { return err } return sess.Commit() } func (s storage) ServerConfigDelete(key string) error { config := &model.ServerConfig{ Key: key, } return wrapDelete(s.engine.Delete(config)) } ================================================ FILE: server/store/datastore/server_config_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestServerConfigGetSet(t *testing.T) { store, closer := newTestStore(t, new(model.ServerConfig)) defer closer() serverConfig := &model.ServerConfig{ Key: "test", Value: "wonderland", } assert.NoError(t, store.ServerConfigSet(serverConfig.Key, serverConfig.Value)) value, err := store.ServerConfigGet(serverConfig.Key) assert.NoError(t, err) assert.Equal(t, serverConfig.Value, value) serverConfig.Value = "new-wonderland" assert.NoError(t, store.ServerConfigSet(serverConfig.Key, serverConfig.Value)) value, err = store.ServerConfigGet(serverConfig.Key) assert.NoError(t, err) assert.Equal(t, serverConfig.Value, value) value, err = store.ServerConfigGet("config_not_exist") assert.Error(t, err) assert.Empty(t, value) } ================================================ FILE: server/store/datastore/step.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "xorm.io/builder" "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) StepLoad(pipelineID, stepID int64) (*model.Step, error) { step := new(model.Step) return step, wrapGet(s.engine.ID(stepID).Where(builder.Eq{"pipeline_id": pipelineID}).Get(step)) } func (s storage) StepByUUID(uuid string) (*model.Step, error) { step := new(model.Step) return step, wrapGet(s.engine.Where( builder.Eq{"uuid": uuid}, ).Get(step)) } func (s storage) StepList(pipelineID int64) ([]*model.Step, error) { stepList := make([]*model.Step, 0) return stepList, s.engine. Where("pipeline_id = ?", pipelineID). OrderBy("pid"). Find(&stepList) } func (s storage) StepListFromWorkflowFind(workflow *model.Workflow) ([]*model.Step, error) { return s.stepListWorkflow(s.engine.NewSession(), workflow) } func (s storage) stepListWorkflow(sess *xorm.Session, workflow *model.Workflow) ([]*model.Step, error) { stepList := make([]*model.Step, 0) return stepList, sess. Where("pipeline_id = ?", workflow.PipelineID). Where("ppid = ?", workflow.PID). OrderBy("pid"). Find(&stepList) } func (s storage) stepCreate(sess *xorm.Session, steps []*model.Step) error { for i := range steps { // only Insert on single object ref set auto created ID back to object if err := wrapInsert(sess.Insert(steps[i])); err != nil { return err } } return nil } func (s storage) StepUpdate(step *model.Step) error { _, err := s.engine.ID(step.ID).AllCols().Update(step) return err } func deleteStep(sess *xorm.Session, stepID int64) error { if err := logDelete(sess, stepID); err != nil { return err } return wrapDelete(sess.ID(stepID).Delete(new(model.Step))) } ================================================ FILE: server/store/datastore/step_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func TestStepList(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline)) defer closer() sess := store.engine.NewSession() err := store.stepCreate(sess, []*model.Step{ { UUID: "2bf387f7-2913-4907-814c-c9ada88707c0", PipelineID: 2, PID: 1, PPID: 1, State: "success", }, { UUID: "4b04073c-1827-4aa4-a5f5-c7b21c5e44a6", PipelineID: 1, PID: 1, PPID: 1, State: "success", }, { UUID: "40aab045-970b-4892-b6df-6f825a7ec97a", PipelineID: 1, PID: 2, PPID: 1, Name: "build", State: "success", }, }) assert.NoError(t, err) _ = sess.Commit() steps, err := store.StepList(1) assert.NoError(t, err) assert.Len(t, steps, 2) } func TestStepUpdate(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline)) defer closer() uuid := "fc7c7fd6-553e-480b-8ed7-30d8563d0b79" step := &model.Step{ UUID: uuid, PipelineID: 1, PID: 1, PPID: 2, Name: "build", State: "pending", Error: "pc load letter", ExitCode: 255, } sess := store.engine.NewSession() assert.NoError(t, store.stepCreate(sess, []*model.Step{step})) _ = sess.Commit() step.State = "running" assert.NoError(t, store.StepUpdate(step)) updated, err := store.StepByUUID(uuid) assert.NoError(t, err) assert.Equal(t, model.StatusRunning, updated.State) } func TestStepIndexes(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline)) defer closer() sess := store.engine.NewSession() defer sess.Close() assert.NoError(t, store.stepCreate(sess, []*model.Step{ { UUID: "4db7e5fc-5312-4d02-9e14-b51b9e3242cc", PipelineID: 1, PID: 1, PPID: 1, State: "running", Name: "build", }, })) // fail due to duplicate pid assert.Error(t, store.stepCreate(sess, []*model.Step{ { UUID: "c1f33a9e-2a02-4579-95ec-90255d785a12", PipelineID: 1, PID: 1, PPID: 1, State: "success", Name: "clone", }, })) } func TestStepByUUID(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline)) defer closer() sess := store.engine.NewSession() assert.NoError(t, store.stepCreate(sess, []*model.Step{ { UUID: "4db7e5fc-5312-4d02-9e14-b51b9e3242cc", PipelineID: 1, PID: 1, PPID: 1, State: "running", Name: "build", }, { UUID: "fc7c7fd6-553e-480b-8ed7-30d8563d0b79", PipelineID: 4, PID: 6, PPID: 7, Name: "build", State: "pending", Error: "pc load letter", ExitCode: 255, }, })) _ = sess.Close() step, err := store.StepByUUID("4db7e5fc-5312-4d02-9e14-b51b9e3242cc") assert.NoError(t, err) assert.NotEmpty(t, step) step, err = store.StepByUUID("52feb6f5-8ce2-40c0-9937-9d0e3349c98c") assert.ErrorIs(t, err, types.ErrRecordNotExist) assert.Empty(t, step) } func TestStepLoad(t *testing.T) { store, closer := newTestStore(t, new(model.Step)) defer closer() sess := store.engine.NewSession() assert.NoError(t, store.stepCreate(sess, []*model.Step{ { UUID: "4db7e5fc-5312-4d02-9e14-b51b9e3242cc", PipelineID: 1, PID: 1, PPID: 1, State: "running", Name: "build", }, { UUID: "fc7c7fd6-553e-480b-8ed7-30d8563d0b79", PipelineID: 4, PID: 6, PPID: 7, Name: "build", State: "pending", Error: "pc load letter", ExitCode: 255, }, })) _ = sess.Close() step, err := store.StepLoad(1, 1) assert.NoError(t, err) assert.NotEmpty(t, step) assert.Equal(t, step.UUID, "4db7e5fc-5312-4d02-9e14-b51b9e3242cc") step, err = store.StepLoad(1, 2) assert.ErrorIs(t, err, types.ErrRecordNotExist) assert.Empty(t, step) } ================================================ FILE: server/store/datastore/task.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) TaskList() ([]*model.Task, error) { tasks := make([]*model.Task, 0, perPage) return tasks, s.engine.Find(&tasks) } func (s storage) TaskInsert(task *model.Task) error { // only Insert set auto created ID back to object return wrapInsert(s.engine.Insert(task)) } func (s storage) TaskDelete(id string) error { return wrapDelete(s.engine.Where("id = ?", id).Delete(new(model.Task))) } ================================================ FILE: server/store/datastore/task_test.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestTaskList(t *testing.T) { store, closer := newTestStore(t, new(model.Task)) defer closer() assert.NoError(t, store.TaskInsert(&model.Task{ ID: "some_random_id", Data: []byte("foo"), Labels: map[string]string{"foo": "bar"}, DepStatus: map[string]model.StatusValue{"test": "dep"}, })) list, err := store.TaskList() assert.NoError(t, err) assert.Len(t, list, 1, "Expected one task in list") assert.Equal(t, "some_random_id", list[0].ID) assert.Equal(t, "foo", string(list[0].Data)) assert.EqualValues(t, map[string]model.StatusValue{"test": "dep"}, list[0].DepStatus) assert.NoError(t, store.TaskDelete("some_random_id")) list, err = store.TaskList() assert.NoError(t, err) assert.Len(t, list, 0, "Want empty task list after delete") } ================================================ FILE: server/store/datastore/user.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "errors" "fmt" "go.woodpecker-ci.org/woodpecker/v3/server/model" "go.woodpecker-ci.org/woodpecker/v3/server/store/types" ) func (s storage) GetUser(id int64) (*model.User, error) { user := new(model.User) return user, wrapGet(s.engine.ID(id).Get(user)) } func (s storage) GetUserByRemoteID(forgeID int64, userRemoteID model.ForgeRemoteID) (*model.User, error) { sess := s.engine.NewSession() user := new(model.User) return user, wrapGet(sess.Where("forge_id = ? AND forge_remote_id = ?", forgeID, userRemoteID).Get(user)) } func (s storage) GetUserByLogin(forgeID int64, login string) (*model.User, error) { sess := s.engine.NewSession() user := new(model.User) return user, wrapGet(sess.Where("forge_id = ? AND login=?", forgeID, login).Get(user)) } func (s storage) GetUserList(p *model.ListOptions) ([]*model.User, error) { var users []*model.User return users, s.paginate(p).OrderBy("login").Find(&users) } func (s storage) GetUserCount() (int64, error) { return s.engine.Count(new(model.User)) } func (s storage) CreateUser(user *model.User) error { sess := s.engine.NewSession() org := &model.Org{ Name: user.Login, ForgeID: user.ForgeID, IsUser: true, } existingOrg, err := s.orgFindByName(sess, org.Name, user.ForgeID) if err != nil && !errors.Is(err, types.ErrRecordNotExist) { return fmt.Errorf("failed to check if org exists: %w", err) } if !errors.Is(err, types.ErrRecordNotExist) { org = existingOrg org.IsUser = true org.Name = user.Login err = s.orgUpdate(sess, org) if err != nil { return fmt.Errorf("failed to update existing org: %w", err) } } else { err = s.orgCreate(org, sess) if err != nil { return fmt.Errorf("failed to create new org: %w", err) } } user.OrgID = org.ID // only Insert set auto created ID back to object return wrapInsert(sess.Insert(user)) } func (s storage) UpdateUser(user *model.User) error { _, err := s.engine.ID(user.ID).AllCols().Update(user) return err } func (s storage) DeleteUser(user *model.User) error { sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } if err := s.orgDelete(sess, user.OrgID); err != nil { return fmt.Errorf("failed to delete org: %w", err) } if err := wrapDelete(sess.ID(user.ID).Delete(new(model.User))); err != nil { return fmt.Errorf("failed to delete user: %w", err) } if _, err := sess.Where("user_id = ?", user.ID).Delete(new(model.Perm)); err != nil { return fmt.Errorf("failed to delete perms: %w", err) } return sess.Commit() } ================================================ FILE: server/store/datastore/user_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestUsers(t *testing.T) { store, closer := newTestStore(t, new(model.User), new(model.Org), new(model.Secret), new(model.Repo), new(model.Perm)) defer closer() count, err := store.GetUserCount() assert.NoError(t, err) assert.Zero(t, count) user := model.User{ Login: "joe", ForgeRemoteID: "joe", AccessToken: "f0b461ca586c27872b43a0685cbc2847", RefreshToken: "976f22a5eef7caacb7e678d6c52f49b1", Email: "foo@bar.com", Avatar: "b9015b0857e16ac4d94a0ffd9a0b79c8", } err = store.CreateUser(&user) assert.NoError(t, err) assert.NotZero(t, user.ID) err2 := store.UpdateUser(&user) assert.NoError(t, err2) getUser, err := store.GetUser(user.ID) assert.NoError(t, err) assert.Equal(t, user.ID, getUser.ID) assert.Equal(t, user.Login, getUser.Login) assert.Equal(t, user.AccessToken, getUser.AccessToken) assert.Equal(t, user.RefreshToken, getUser.RefreshToken) assert.Equal(t, user.Email, getUser.Email) assert.Equal(t, user.Avatar, getUser.Avatar) getUser, err = store.GetUserByLogin(user.ForgeID, user.Login) assert.NoError(t, err) assert.Equal(t, user.ID, getUser.ID) assert.Equal(t, user.Login, getUser.Login) // check unique login user2 := model.User{ Login: "Joe", ForgeRemoteID: "joe", Email: "foo2@bar.com", AccessToken: "ab20g0ddaf012c744e136da16aa21ad9", } err2 = store.CreateUser(&user2) assert.Error(t, err2) user2 = model.User{ Login: "jane", ForgeRemoteID: "jane", Email: "foo@bar.com", AccessToken: "ab20g0ddaf012c744e136da16aa21ad9", Hash: "A", } assert.NoError(t, store.CreateUser(&user2)) users, err := store.GetUserList(&model.ListOptions{Page: 1, PerPage: 50}) assert.NoError(t, err) assert.Len(t, users, 2) // "jane" user is first due to alphabetic sorting assert.Equal(t, user2.Login, users[0].Login) assert.Equal(t, user2.Email, users[0].Email) assert.Equal(t, user2.AccessToken, users[0].AccessToken) count, err = store.GetUserCount() assert.NoError(t, err) assert.EqualValues(t, 2, count) getUser, err1 := store.GetUser(user.ID) assert.NoError(t, err1) err2 = store.DeleteUser(getUser) assert.NoError(t, err2) _, err3 := store.GetUser(getUser.ID) assert.Error(t, err3) } func TestCreateUserWithExistingOrg(t *testing.T) { store, closer := newTestStore(t, new(model.User), new(model.Org), new(model.Perm)) defer closer() existingOrg := &model.Org{ ForgeID: 1, IsUser: true, Name: "existingOrg", Private: false, } err := store.OrgCreate(existingOrg) assert.NoError(t, err) assert.EqualValues(t, "existingOrg", existingOrg.Name) // Create a new user with the same name as the existing organization newUser := &model.User{ Login: "existingOrg", ForgeRemoteID: "A", Hash: "A", ForgeID: 1, } err = store.CreateUser(newUser) assert.NoError(t, err) updatedOrg, err := store.OrgGet(existingOrg.ID) assert.NoError(t, err) assert.Equal(t, "existingOrg", updatedOrg.Name) newUser2 := &model.User{ Login: "new-user", ForgeRemoteID: "B", ForgeID: 1, Hash: "B", } err = store.CreateUser(newUser2) assert.NoError(t, err) newOrg, err := store.OrgFindByName("new-user", 1) assert.NoError(t, err) assert.Equal(t, "new-user", newOrg.Name) } ================================================ FILE: server/store/datastore/workflow.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "xorm.io/xorm" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func (s storage) WorkflowGetTree(pipeline *model.Pipeline) ([]*model.Workflow, error) { sess := s.engine.NewSession() wfList, err := s.workflowList(sess, pipeline) if err != nil { return nil, err } for _, wf := range wfList { wf.Children, err = s.stepListWorkflow(sess, wf) if err != nil { return nil, err } } return wfList, sess.Commit() } func (s storage) WorkflowsCreate(workflows []*model.Workflow) error { sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } if err := s.workflowsCreate(sess, workflows); err != nil { return err } return sess.Commit() } func (s storage) workflowsCreate(sess *xorm.Session, workflows []*model.Workflow) error { for i := range workflows { // only Insert on single object ref set auto created ID back to object if err := s.stepCreate(sess, workflows[i].Children); err != nil { return err } if err := wrapInsert(sess.Insert(workflows[i])); err != nil { return err } } return nil } // WorkflowsReplace performs an atomic replacement of workflows and associated steps by deleting all existing workflows and steps and inserting the new ones. func (s storage) WorkflowsReplace(pipeline *model.Pipeline, workflows []*model.Workflow) error { sess := s.engine.NewSession() defer sess.Close() if err := sess.Begin(); err != nil { return err } if err := s.workflowsDelete(sess, pipeline.ID); err != nil { return err } if err := s.workflowsCreate(sess, workflows); err != nil { return err } return sess.Commit() } func (s storage) workflowsDelete(sess *xorm.Session, pipelineID int64) error { // delete related steps for { stepIDs := make([]int64, 0, perPage) if err := sess.Limit(perPage).Table("steps").Cols("id").Where("pipeline_id = ?", pipelineID).Find(&stepIDs); err != nil { return err } if len(stepIDs) == 0 { break } for i := range stepIDs { if err := deleteStep(sess, stepIDs[i]); err != nil { return err } } } _, err := sess.Where("pipeline_id = ?", pipelineID).Delete(new(model.Workflow)) return err } func (s storage) WorkflowList(pipeline *model.Pipeline) ([]*model.Workflow, error) { return s.workflowList(s.engine.NewSession(), pipeline) } // workflowList lists workflows without child steps. func (s storage) workflowList(sess *xorm.Session, pipeline *model.Pipeline) ([]*model.Workflow, error) { var wfList []*model.Workflow err := sess.Where("pipeline_id = ?", pipeline.ID). OrderBy("pid"). Find(&wfList) if err != nil { return nil, err } return wfList, nil } func (s storage) WorkflowLoad(id int64) (*model.Workflow, error) { workflow := new(model.Workflow) return workflow, wrapGet(s.engine.ID(id).Get(workflow)) } func (s storage) WorkflowUpdate(workflow *model.Workflow) error { _, err := s.engine.ID(workflow.ID).AllCols().Update(workflow) return err } ================================================ FILE: server/store/datastore/workflow_test.go ================================================ // Copyright 2022 Woodpecker Authors // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) func TestWorkflowLoad(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline), new(model.Workflow)) defer closer() wf := &model.Workflow{ PipelineID: 1, PID: 1, Name: "woodpecker", Children: []*model.Step{ { UUID: "ea6d4008-8ace-4f8a-ad03-53f1756465d9", PipelineID: 1, PID: 2, PPID: 1, State: "success", }, { UUID: "2bf387f7-2913-4907-814c-c9ada88707c0", PipelineID: 1, PID: 3, PPID: 1, Name: "build", State: "success", }, }, } assert.NoError(t, store.WorkflowsCreate([]*model.Workflow{wf})) workflowGet, err := store.WorkflowLoad(1) assert.NoError(t, err) assert.EqualValues(t, 1, workflowGet.PipelineID) assert.Equal(t, 1, workflowGet.PID) assert.Len(t, workflowGet.Children, 0) } func TestWorkflowGetTree(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline), new(model.Workflow)) defer closer() wf := &model.Workflow{ PipelineID: 1, PID: 1, Name: "woodpecker", Children: []*model.Step{ { UUID: "ea6d4008-8ace-4f8a-ad03-53f1756465d9", PipelineID: 1, PID: 2, PPID: 1, State: "success", }, { UUID: "2bf387f7-2913-4907-814c-c9ada88707c0", PipelineID: 1, PID: 3, PPID: 1, Name: "build", State: "success", }, }, } assert.NoError(t, store.WorkflowsCreate([]*model.Workflow{wf})) workflowsGet, err := store.WorkflowGetTree(&model.Pipeline{ID: 1}) assert.NoError(t, err) assert.Len(t, workflowsGet, 1) workflowGet := workflowsGet[0] assert.Equal(t, "woodpecker", workflowGet.Name) assert.Len(t, workflowGet.Children, 2) assert.Equal(t, 2, workflowGet.Children[0].PID) assert.Equal(t, 3, workflowGet.Children[1].PID) } func TestWorkflowUpdate(t *testing.T) { store, closer := newTestStore(t, new(model.Step), new(model.Pipeline), new(model.Workflow)) defer closer() wf := &model.Workflow{ PipelineID: 1, PID: 1, Name: "woodpecker", State: "pending", } assert.NoError(t, store.WorkflowsCreate([]*model.Workflow{wf})) workflowGet, err := store.WorkflowLoad(1) assert.NoError(t, err) assert.Equal(t, model.StatusValue("pending"), workflowGet.State) wf.State = "success" assert.NoError(t, store.WorkflowUpdate(wf)) workflowGet, err = store.WorkflowLoad(1) assert.NoError(t, err) assert.Equal(t, model.StatusValue("success"), workflowGet.State) } ================================================ FILE: server/store/datastore/xorm.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package datastore import ( "fmt" "github.com/rs/zerolog" "github.com/rs/zerolog/log" xlog "xorm.io/xorm/log" ) func newXORMLogger(level xlog.LogLevel) xlog.Logger { return &xormLogger{ logger: log.With().Str("component", "xorm").Logger(), level: level, } } // xormLogger custom log implementation for ILogger. type xormLogger struct { logger zerolog.Logger level xlog.LogLevel showSQL bool } // Error implement ILogger. func (x *xormLogger) Error(v ...any) { if x.level <= xlog.LOG_ERR { x.logger.Error().Msg(fmt.Sprintln(v...)) } } // Errorf implement ILogger. func (x *xormLogger) Errorf(format string, v ...any) { if x.level <= xlog.LOG_ERR { x.logger.Error().Msg(fmt.Sprintf(format, v...)) } } // Debug implement ILogger. func (x *xormLogger) Debug(v ...any) { if x.level <= xlog.LOG_DEBUG { x.logger.Debug().Msg(fmt.Sprintln(v...)) } } // Debugf implement ILogger. func (x *xormLogger) Debugf(format string, v ...any) { if x.level <= xlog.LOG_DEBUG { x.logger.Debug().Msg(fmt.Sprintf(format, v...)) } } // Info implement ILogger. func (x *xormLogger) Info(v ...any) { if x.level <= xlog.LOG_INFO { x.logger.Info().Msg(fmt.Sprintln(v...)) } } // Infof implement ILogger. func (x *xormLogger) Infof(format string, v ...any) { if x.level <= xlog.LOG_INFO { x.logger.Info().Msg(fmt.Sprintf(format, v...)) } } // Warn implement ILogger. func (x *xormLogger) Warn(v ...any) { if x.level <= xlog.LOG_WARNING { x.logger.Warn().Msg(fmt.Sprintln(v...)) } } // Warnf implement ILogger. func (x *xormLogger) Warnf(format string, v ...any) { if x.level <= xlog.LOG_WARNING { x.logger.Warn().Msg(fmt.Sprintf(format, v...)) } } // Level implement ILogger. func (x *xormLogger) Level() xlog.LogLevel { return xlog.LOG_INFO } // SetLevel implement ILogger. func (x *xormLogger) SetLevel(l xlog.LogLevel) { x.level = l } // ShowSQL implement ILogger. func (x *xormLogger) ShowSQL(show ...bool) { if len(show) == 0 { x.showSQL = true return } x.showSQL = show[0] } // IsShowSQL implement ILogger. func (x *xormLogger) IsShowSQL() bool { return x.showSQL } ================================================ FILE: server/store/mocks/mock_Store.go ================================================ // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify package mocks import ( "context" mock "github.com/stretchr/testify/mock" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockStore(t interface { mock.TestingT Cleanup(func()) }) *MockStore { mock := &MockStore{} mock.Mock.Test(t) t.Cleanup(func() { mock.AssertExpectations(t) }) return mock } // MockStore is an autogenerated mock type for the Store type type MockStore struct { mock.Mock } type MockStore_Expecter struct { mock *mock.Mock } func (_m *MockStore) EXPECT() *MockStore_Expecter { return &MockStore_Expecter{mock: &_m.Mock} } // AgentCreate provides a mock function for the type MockStore func (_mock *MockStore) AgentCreate(agent *model.Agent) error { ret := _mock.Called(agent) if len(ret) == 0 { panic("no return value specified for AgentCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Agent) error); ok { r0 = returnFunc(agent) } else { r0 = ret.Error(0) } return r0 } // MockStore_AgentCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentCreate' type MockStore_AgentCreate_Call struct { *mock.Call } // AgentCreate is a helper method to define mock.On call // - agent *model.Agent func (_e *MockStore_Expecter) AgentCreate(agent interface{}) *MockStore_AgentCreate_Call { return &MockStore_AgentCreate_Call{Call: _e.mock.On("AgentCreate", agent)} } func (_c *MockStore_AgentCreate_Call) Run(run func(agent *model.Agent)) *MockStore_AgentCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Agent if args[0] != nil { arg0 = args[0].(*model.Agent) } run( arg0, ) }) return _c } func (_c *MockStore_AgentCreate_Call) Return(err error) *MockStore_AgentCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_AgentCreate_Call) RunAndReturn(run func(agent *model.Agent) error) *MockStore_AgentCreate_Call { _c.Call.Return(run) return _c } // AgentDelete provides a mock function for the type MockStore func (_mock *MockStore) AgentDelete(agent *model.Agent) error { ret := _mock.Called(agent) if len(ret) == 0 { panic("no return value specified for AgentDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Agent) error); ok { r0 = returnFunc(agent) } else { r0 = ret.Error(0) } return r0 } // MockStore_AgentDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentDelete' type MockStore_AgentDelete_Call struct { *mock.Call } // AgentDelete is a helper method to define mock.On call // - agent *model.Agent func (_e *MockStore_Expecter) AgentDelete(agent interface{}) *MockStore_AgentDelete_Call { return &MockStore_AgentDelete_Call{Call: _e.mock.On("AgentDelete", agent)} } func (_c *MockStore_AgentDelete_Call) Run(run func(agent *model.Agent)) *MockStore_AgentDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Agent if args[0] != nil { arg0 = args[0].(*model.Agent) } run( arg0, ) }) return _c } func (_c *MockStore_AgentDelete_Call) Return(err error) *MockStore_AgentDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_AgentDelete_Call) RunAndReturn(run func(agent *model.Agent) error) *MockStore_AgentDelete_Call { _c.Call.Return(run) return _c } // AgentFind provides a mock function for the type MockStore func (_mock *MockStore) AgentFind(n int64) (*model.Agent, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for AgentFind") } var r0 *model.Agent var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.Agent, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.Agent); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Agent) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_AgentFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentFind' type MockStore_AgentFind_Call struct { *mock.Call } // AgentFind is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) AgentFind(n interface{}) *MockStore_AgentFind_Call { return &MockStore_AgentFind_Call{Call: _e.mock.On("AgentFind", n)} } func (_c *MockStore_AgentFind_Call) Run(run func(n int64)) *MockStore_AgentFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_AgentFind_Call) Return(agent *model.Agent, err error) *MockStore_AgentFind_Call { _c.Call.Return(agent, err) return _c } func (_c *MockStore_AgentFind_Call) RunAndReturn(run func(n int64) (*model.Agent, error)) *MockStore_AgentFind_Call { _c.Call.Return(run) return _c } // AgentFindByToken provides a mock function for the type MockStore func (_mock *MockStore) AgentFindByToken(s string) (*model.Agent, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for AgentFindByToken") } var r0 *model.Agent var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Agent, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Agent); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Agent) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_AgentFindByToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentFindByToken' type MockStore_AgentFindByToken_Call struct { *mock.Call } // AgentFindByToken is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) AgentFindByToken(s interface{}) *MockStore_AgentFindByToken_Call { return &MockStore_AgentFindByToken_Call{Call: _e.mock.On("AgentFindByToken", s)} } func (_c *MockStore_AgentFindByToken_Call) Run(run func(s string)) *MockStore_AgentFindByToken_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_AgentFindByToken_Call) Return(agent *model.Agent, err error) *MockStore_AgentFindByToken_Call { _c.Call.Return(agent, err) return _c } func (_c *MockStore_AgentFindByToken_Call) RunAndReturn(run func(s string) (*model.Agent, error)) *MockStore_AgentFindByToken_Call { _c.Call.Return(run) return _c } // AgentList provides a mock function for the type MockStore func (_mock *MockStore) AgentList(p *model.ListOptions) ([]*model.Agent, error) { ret := _mock.Called(p) if len(ret) == 0 { panic("no return value specified for AgentList") } var r0 []*model.Agent var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Agent, error)); ok { return returnFunc(p) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Agent); ok { r0 = returnFunc(p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Agent) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(p) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_AgentList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentList' type MockStore_AgentList_Call struct { *mock.Call } // AgentList is a helper method to define mock.On call // - p *model.ListOptions func (_e *MockStore_Expecter) AgentList(p interface{}) *MockStore_AgentList_Call { return &MockStore_AgentList_Call{Call: _e.mock.On("AgentList", p)} } func (_c *MockStore_AgentList_Call) Run(run func(p *model.ListOptions)) *MockStore_AgentList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockStore_AgentList_Call) Return(agents []*model.Agent, err error) *MockStore_AgentList_Call { _c.Call.Return(agents, err) return _c } func (_c *MockStore_AgentList_Call) RunAndReturn(run func(p *model.ListOptions) ([]*model.Agent, error)) *MockStore_AgentList_Call { _c.Call.Return(run) return _c } // AgentListForOrg provides a mock function for the type MockStore func (_mock *MockStore) AgentListForOrg(orgID int64, opt *model.ListOptions) ([]*model.Agent, error) { ret := _mock.Called(orgID, opt) if len(ret) == 0 { panic("no return value specified for AgentListForOrg") } var r0 []*model.Agent var r1 error if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Agent, error)); ok { return returnFunc(orgID, opt) } if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Agent); ok { r0 = returnFunc(orgID, opt) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Agent) } } if returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { r1 = returnFunc(orgID, opt) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_AgentListForOrg_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentListForOrg' type MockStore_AgentListForOrg_Call struct { *mock.Call } // AgentListForOrg is a helper method to define mock.On call // - orgID int64 // - opt *model.ListOptions func (_e *MockStore_Expecter) AgentListForOrg(orgID interface{}, opt interface{}) *MockStore_AgentListForOrg_Call { return &MockStore_AgentListForOrg_Call{Call: _e.mock.On("AgentListForOrg", orgID, opt)} } func (_c *MockStore_AgentListForOrg_Call) Run(run func(orgID int64, opt *model.ListOptions)) *MockStore_AgentListForOrg_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_AgentListForOrg_Call) Return(agents []*model.Agent, err error) *MockStore_AgentListForOrg_Call { _c.Call.Return(agents, err) return _c } func (_c *MockStore_AgentListForOrg_Call) RunAndReturn(run func(orgID int64, opt *model.ListOptions) ([]*model.Agent, error)) *MockStore_AgentListForOrg_Call { _c.Call.Return(run) return _c } // AgentUpdate provides a mock function for the type MockStore func (_mock *MockStore) AgentUpdate(agent *model.Agent) error { ret := _mock.Called(agent) if len(ret) == 0 { panic("no return value specified for AgentUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Agent) error); ok { r0 = returnFunc(agent) } else { r0 = ret.Error(0) } return r0 } // MockStore_AgentUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AgentUpdate' type MockStore_AgentUpdate_Call struct { *mock.Call } // AgentUpdate is a helper method to define mock.On call // - agent *model.Agent func (_e *MockStore_Expecter) AgentUpdate(agent interface{}) *MockStore_AgentUpdate_Call { return &MockStore_AgentUpdate_Call{Call: _e.mock.On("AgentUpdate", agent)} } func (_c *MockStore_AgentUpdate_Call) Run(run func(agent *model.Agent)) *MockStore_AgentUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Agent if args[0] != nil { arg0 = args[0].(*model.Agent) } run( arg0, ) }) return _c } func (_c *MockStore_AgentUpdate_Call) Return(err error) *MockStore_AgentUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_AgentUpdate_Call) RunAndReturn(run func(agent *model.Agent) error) *MockStore_AgentUpdate_Call { _c.Call.Return(run) return _c } // Close provides a mock function for the type MockStore func (_mock *MockStore) Close() error { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for Close") } var r0 error if returnFunc, ok := ret.Get(0).(func() error); ok { r0 = returnFunc() } else { r0 = ret.Error(0) } return r0 } // MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' type MockStore_Close_Call struct { *mock.Call } // Close is a helper method to define mock.On call func (_e *MockStore_Expecter) Close() *MockStore_Close_Call { return &MockStore_Close_Call{Call: _e.mock.On("Close")} } func (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call { _c.Call.Return(err) return _c } func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call { _c.Call.Return(run) return _c } // ConfigPersist provides a mock function for the type MockStore func (_mock *MockStore) ConfigPersist(config *model.Config) (*model.Config, error) { ret := _mock.Called(config) if len(ret) == 0 { panic("no return value specified for ConfigPersist") } var r0 *model.Config var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Config) (*model.Config, error)); ok { return returnFunc(config) } if returnFunc, ok := ret.Get(0).(func(*model.Config) *model.Config); ok { r0 = returnFunc(config) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Config) } } if returnFunc, ok := ret.Get(1).(func(*model.Config) error); ok { r1 = returnFunc(config) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_ConfigPersist_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigPersist' type MockStore_ConfigPersist_Call struct { *mock.Call } // ConfigPersist is a helper method to define mock.On call // - config *model.Config func (_e *MockStore_Expecter) ConfigPersist(config interface{}) *MockStore_ConfigPersist_Call { return &MockStore_ConfigPersist_Call{Call: _e.mock.On("ConfigPersist", config)} } func (_c *MockStore_ConfigPersist_Call) Run(run func(config *model.Config)) *MockStore_ConfigPersist_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Config if args[0] != nil { arg0 = args[0].(*model.Config) } run( arg0, ) }) return _c } func (_c *MockStore_ConfigPersist_Call) Return(config1 *model.Config, err error) *MockStore_ConfigPersist_Call { _c.Call.Return(config1, err) return _c } func (_c *MockStore_ConfigPersist_Call) RunAndReturn(run func(config *model.Config) (*model.Config, error)) *MockStore_ConfigPersist_Call { _c.Call.Return(run) return _c } // ConfigsForPipeline provides a mock function for the type MockStore func (_mock *MockStore) ConfigsForPipeline(pipelineID int64) ([]*model.Config, error) { ret := _mock.Called(pipelineID) if len(ret) == 0 { panic("no return value specified for ConfigsForPipeline") } var r0 []*model.Config var r1 error if returnFunc, ok := ret.Get(0).(func(int64) ([]*model.Config, error)); ok { return returnFunc(pipelineID) } if returnFunc, ok := ret.Get(0).(func(int64) []*model.Config); ok { r0 = returnFunc(pipelineID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Config) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(pipelineID) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_ConfigsForPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfigsForPipeline' type MockStore_ConfigsForPipeline_Call struct { *mock.Call } // ConfigsForPipeline is a helper method to define mock.On call // - pipelineID int64 func (_e *MockStore_Expecter) ConfigsForPipeline(pipelineID interface{}) *MockStore_ConfigsForPipeline_Call { return &MockStore_ConfigsForPipeline_Call{Call: _e.mock.On("ConfigsForPipeline", pipelineID)} } func (_c *MockStore_ConfigsForPipeline_Call) Run(run func(pipelineID int64)) *MockStore_ConfigsForPipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_ConfigsForPipeline_Call) Return(configs []*model.Config, err error) *MockStore_ConfigsForPipeline_Call { _c.Call.Return(configs, err) return _c } func (_c *MockStore_ConfigsForPipeline_Call) RunAndReturn(run func(pipelineID int64) ([]*model.Config, error)) *MockStore_ConfigsForPipeline_Call { _c.Call.Return(run) return _c } // CreatePipeline provides a mock function for the type MockStore func (_mock *MockStore) CreatePipeline(pipeline *model.Pipeline, steps ...*model.Step) error { var tmpRet mock.Arguments if len(steps) > 0 { tmpRet = _mock.Called(pipeline, steps) } else { tmpRet = _mock.Called(pipeline) } ret := tmpRet if len(ret) == 0 { panic("no return value specified for CreatePipeline") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Pipeline, ...*model.Step) error); ok { r0 = returnFunc(pipeline, steps...) } else { r0 = ret.Error(0) } return r0 } // MockStore_CreatePipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreatePipeline' type MockStore_CreatePipeline_Call struct { *mock.Call } // CreatePipeline is a helper method to define mock.On call // - pipeline *model.Pipeline // - steps ...*model.Step func (_e *MockStore_Expecter) CreatePipeline(pipeline interface{}, steps ...interface{}) *MockStore_CreatePipeline_Call { return &MockStore_CreatePipeline_Call{Call: _e.mock.On("CreatePipeline", append([]interface{}{pipeline}, steps...)...)} } func (_c *MockStore_CreatePipeline_Call) Run(run func(pipeline *model.Pipeline, steps ...*model.Step)) *MockStore_CreatePipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Pipeline if args[0] != nil { arg0 = args[0].(*model.Pipeline) } var arg1 []*model.Step var variadicArgs []*model.Step if len(args) > 1 { variadicArgs = args[1].([]*model.Step) } arg1 = variadicArgs run( arg0, arg1..., ) }) return _c } func (_c *MockStore_CreatePipeline_Call) Return(err error) *MockStore_CreatePipeline_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CreatePipeline_Call) RunAndReturn(run func(pipeline *model.Pipeline, steps ...*model.Step) error) *MockStore_CreatePipeline_Call { _c.Call.Return(run) return _c } // CreateRedirection provides a mock function for the type MockStore func (_mock *MockStore) CreateRedirection(redirection *model.Redirection) error { ret := _mock.Called(redirection) if len(ret) == 0 { panic("no return value specified for CreateRedirection") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Redirection) error); ok { r0 = returnFunc(redirection) } else { r0 = ret.Error(0) } return r0 } // MockStore_CreateRedirection_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRedirection' type MockStore_CreateRedirection_Call struct { *mock.Call } // CreateRedirection is a helper method to define mock.On call // - redirection *model.Redirection func (_e *MockStore_Expecter) CreateRedirection(redirection interface{}) *MockStore_CreateRedirection_Call { return &MockStore_CreateRedirection_Call{Call: _e.mock.On("CreateRedirection", redirection)} } func (_c *MockStore_CreateRedirection_Call) Run(run func(redirection *model.Redirection)) *MockStore_CreateRedirection_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Redirection if args[0] != nil { arg0 = args[0].(*model.Redirection) } run( arg0, ) }) return _c } func (_c *MockStore_CreateRedirection_Call) Return(err error) *MockStore_CreateRedirection_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CreateRedirection_Call) RunAndReturn(run func(redirection *model.Redirection) error) *MockStore_CreateRedirection_Call { _c.Call.Return(run) return _c } // CreateRepo provides a mock function for the type MockStore func (_mock *MockStore) CreateRepo(repo *model.Repo) error { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for CreateRepo") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo) error); ok { r0 = returnFunc(repo) } else { r0 = ret.Error(0) } return r0 } // MockStore_CreateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRepo' type MockStore_CreateRepo_Call struct { *mock.Call } // CreateRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockStore_Expecter) CreateRepo(repo interface{}) *MockStore_CreateRepo_Call { return &MockStore_CreateRepo_Call{Call: _e.mock.On("CreateRepo", repo)} } func (_c *MockStore_CreateRepo_Call) Run(run func(repo *model.Repo)) *MockStore_CreateRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockStore_CreateRepo_Call) Return(err error) *MockStore_CreateRepo_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CreateRepo_Call) RunAndReturn(run func(repo *model.Repo) error) *MockStore_CreateRepo_Call { _c.Call.Return(run) return _c } // CreateUser provides a mock function for the type MockStore func (_mock *MockStore) CreateUser(user *model.User) error { ret := _mock.Called(user) if len(ret) == 0 { panic("no return value specified for CreateUser") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.User) error); ok { r0 = returnFunc(user) } else { r0 = ret.Error(0) } return r0 } // MockStore_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser' type MockStore_CreateUser_Call struct { *mock.Call } // CreateUser is a helper method to define mock.On call // - user *model.User func (_e *MockStore_Expecter) CreateUser(user interface{}) *MockStore_CreateUser_Call { return &MockStore_CreateUser_Call{Call: _e.mock.On("CreateUser", user)} } func (_c *MockStore_CreateUser_Call) Run(run func(user *model.User)) *MockStore_CreateUser_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } run( arg0, ) }) return _c } func (_c *MockStore_CreateUser_Call) Return(err error) *MockStore_CreateUser_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CreateUser_Call) RunAndReturn(run func(user *model.User) error) *MockStore_CreateUser_Call { _c.Call.Return(run) return _c } // CronCreate provides a mock function for the type MockStore func (_mock *MockStore) CronCreate(cron *model.Cron) error { ret := _mock.Called(cron) if len(ret) == 0 { panic("no return value specified for CronCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Cron) error); ok { r0 = returnFunc(cron) } else { r0 = ret.Error(0) } return r0 } // MockStore_CronCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronCreate' type MockStore_CronCreate_Call struct { *mock.Call } // CronCreate is a helper method to define mock.On call // - cron *model.Cron func (_e *MockStore_Expecter) CronCreate(cron interface{}) *MockStore_CronCreate_Call { return &MockStore_CronCreate_Call{Call: _e.mock.On("CronCreate", cron)} } func (_c *MockStore_CronCreate_Call) Run(run func(cron *model.Cron)) *MockStore_CronCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Cron if args[0] != nil { arg0 = args[0].(*model.Cron) } run( arg0, ) }) return _c } func (_c *MockStore_CronCreate_Call) Return(err error) *MockStore_CronCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CronCreate_Call) RunAndReturn(run func(cron *model.Cron) error) *MockStore_CronCreate_Call { _c.Call.Return(run) return _c } // CronDelete provides a mock function for the type MockStore func (_mock *MockStore) CronDelete(repo *model.Repo, n int64) error { ret := _mock.Called(repo, n) if len(ret) == 0 { panic("no return value specified for CronDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) error); ok { r0 = returnFunc(repo, n) } else { r0 = ret.Error(0) } return r0 } // MockStore_CronDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronDelete' type MockStore_CronDelete_Call struct { *mock.Call } // CronDelete is a helper method to define mock.On call // - repo *model.Repo // - n int64 func (_e *MockStore_Expecter) CronDelete(repo interface{}, n interface{}) *MockStore_CronDelete_Call { return &MockStore_CronDelete_Call{Call: _e.mock.On("CronDelete", repo, n)} } func (_c *MockStore_CronDelete_Call) Run(run func(repo *model.Repo, n int64)) *MockStore_CronDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_CronDelete_Call) Return(err error) *MockStore_CronDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CronDelete_Call) RunAndReturn(run func(repo *model.Repo, n int64) error) *MockStore_CronDelete_Call { _c.Call.Return(run) return _c } // CronFind provides a mock function for the type MockStore func (_mock *MockStore) CronFind(repo *model.Repo, n int64) (*model.Cron, error) { ret := _mock.Called(repo, n) if len(ret) == 0 { panic("no return value specified for CronFind") } var r0 *model.Cron var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) (*model.Cron, error)); ok { return returnFunc(repo, n) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) *model.Cron); ok { r0 = returnFunc(repo, n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Cron) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, int64) error); ok { r1 = returnFunc(repo, n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_CronFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronFind' type MockStore_CronFind_Call struct { *mock.Call } // CronFind is a helper method to define mock.On call // - repo *model.Repo // - n int64 func (_e *MockStore_Expecter) CronFind(repo interface{}, n interface{}) *MockStore_CronFind_Call { return &MockStore_CronFind_Call{Call: _e.mock.On("CronFind", repo, n)} } func (_c *MockStore_CronFind_Call) Run(run func(repo *model.Repo, n int64)) *MockStore_CronFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_CronFind_Call) Return(cron *model.Cron, err error) *MockStore_CronFind_Call { _c.Call.Return(cron, err) return _c } func (_c *MockStore_CronFind_Call) RunAndReturn(run func(repo *model.Repo, n int64) (*model.Cron, error)) *MockStore_CronFind_Call { _c.Call.Return(run) return _c } // CronGetLock provides a mock function for the type MockStore func (_mock *MockStore) CronGetLock(cron *model.Cron, n int64) (bool, error) { ret := _mock.Called(cron, n) if len(ret) == 0 { panic("no return value specified for CronGetLock") } var r0 bool var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Cron, int64) (bool, error)); ok { return returnFunc(cron, n) } if returnFunc, ok := ret.Get(0).(func(*model.Cron, int64) bool); ok { r0 = returnFunc(cron, n) } else { r0 = ret.Get(0).(bool) } if returnFunc, ok := ret.Get(1).(func(*model.Cron, int64) error); ok { r1 = returnFunc(cron, n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_CronGetLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronGetLock' type MockStore_CronGetLock_Call struct { *mock.Call } // CronGetLock is a helper method to define mock.On call // - cron *model.Cron // - n int64 func (_e *MockStore_Expecter) CronGetLock(cron interface{}, n interface{}) *MockStore_CronGetLock_Call { return &MockStore_CronGetLock_Call{Call: _e.mock.On("CronGetLock", cron, n)} } func (_c *MockStore_CronGetLock_Call) Run(run func(cron *model.Cron, n int64)) *MockStore_CronGetLock_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Cron if args[0] != nil { arg0 = args[0].(*model.Cron) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_CronGetLock_Call) Return(b bool, err error) *MockStore_CronGetLock_Call { _c.Call.Return(b, err) return _c } func (_c *MockStore_CronGetLock_Call) RunAndReturn(run func(cron *model.Cron, n int64) (bool, error)) *MockStore_CronGetLock_Call { _c.Call.Return(run) return _c } // CronList provides a mock function for the type MockStore func (_mock *MockStore) CronList(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Cron, error) { ret := _mock.Called(repo, listOptions) if len(ret) == 0 { panic("no return value specified for CronList") } var r0 []*model.Cron var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) ([]*model.Cron, error)); ok { return returnFunc(repo, listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions) []*model.Cron); ok { r0 = returnFunc(repo, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Cron) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions) error); ok { r1 = returnFunc(repo, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_CronList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronList' type MockStore_CronList_Call struct { *mock.Call } // CronList is a helper method to define mock.On call // - repo *model.Repo // - listOptions *model.ListOptions func (_e *MockStore_Expecter) CronList(repo interface{}, listOptions interface{}) *MockStore_CronList_Call { return &MockStore_CronList_Call{Call: _e.mock.On("CronList", repo, listOptions)} } func (_c *MockStore_CronList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions)) *MockStore_CronList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_CronList_Call) Return(crons []*model.Cron, err error) *MockStore_CronList_Call { _c.Call.Return(crons, err) return _c } func (_c *MockStore_CronList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions) ([]*model.Cron, error)) *MockStore_CronList_Call { _c.Call.Return(run) return _c } // CronListNextExecute provides a mock function for the type MockStore func (_mock *MockStore) CronListNextExecute(n int64, n1 int64) ([]*model.Cron, error) { ret := _mock.Called(n, n1) if len(ret) == 0 { panic("no return value specified for CronListNextExecute") } var r0 []*model.Cron var r1 error if returnFunc, ok := ret.Get(0).(func(int64, int64) ([]*model.Cron, error)); ok { return returnFunc(n, n1) } if returnFunc, ok := ret.Get(0).(func(int64, int64) []*model.Cron); ok { r0 = returnFunc(n, n1) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Cron) } } if returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok { r1 = returnFunc(n, n1) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_CronListNextExecute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronListNextExecute' type MockStore_CronListNextExecute_Call struct { *mock.Call } // CronListNextExecute is a helper method to define mock.On call // - n int64 // - n1 int64 func (_e *MockStore_Expecter) CronListNextExecute(n interface{}, n1 interface{}) *MockStore_CronListNextExecute_Call { return &MockStore_CronListNextExecute_Call{Call: _e.mock.On("CronListNextExecute", n, n1)} } func (_c *MockStore_CronListNextExecute_Call) Run(run func(n int64, n1 int64)) *MockStore_CronListNextExecute_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_CronListNextExecute_Call) Return(crons []*model.Cron, err error) *MockStore_CronListNextExecute_Call { _c.Call.Return(crons, err) return _c } func (_c *MockStore_CronListNextExecute_Call) RunAndReturn(run func(n int64, n1 int64) ([]*model.Cron, error)) *MockStore_CronListNextExecute_Call { _c.Call.Return(run) return _c } // CronUpdate provides a mock function for the type MockStore func (_mock *MockStore) CronUpdate(repo *model.Repo, cron *model.Cron) error { ret := _mock.Called(repo, cron) if len(ret) == 0 { panic("no return value specified for CronUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Cron) error); ok { r0 = returnFunc(repo, cron) } else { r0 = ret.Error(0) } return r0 } // MockStore_CronUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CronUpdate' type MockStore_CronUpdate_Call struct { *mock.Call } // CronUpdate is a helper method to define mock.On call // - repo *model.Repo // - cron *model.Cron func (_e *MockStore_Expecter) CronUpdate(repo interface{}, cron interface{}) *MockStore_CronUpdate_Call { return &MockStore_CronUpdate_Call{Call: _e.mock.On("CronUpdate", repo, cron)} } func (_c *MockStore_CronUpdate_Call) Run(run func(repo *model.Repo, cron *model.Cron)) *MockStore_CronUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.Cron if args[1] != nil { arg1 = args[1].(*model.Cron) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_CronUpdate_Call) Return(err error) *MockStore_CronUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_CronUpdate_Call) RunAndReturn(run func(repo *model.Repo, cron *model.Cron) error) *MockStore_CronUpdate_Call { _c.Call.Return(run) return _c } // DeletePipeline provides a mock function for the type MockStore func (_mock *MockStore) DeletePipeline(pipeline *model.Pipeline) error { ret := _mock.Called(pipeline) if len(ret) == 0 { panic("no return value specified for DeletePipeline") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Pipeline) error); ok { r0 = returnFunc(pipeline) } else { r0 = ret.Error(0) } return r0 } // MockStore_DeletePipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeletePipeline' type MockStore_DeletePipeline_Call struct { *mock.Call } // DeletePipeline is a helper method to define mock.On call // - pipeline *model.Pipeline func (_e *MockStore_Expecter) DeletePipeline(pipeline interface{}) *MockStore_DeletePipeline_Call { return &MockStore_DeletePipeline_Call{Call: _e.mock.On("DeletePipeline", pipeline)} } func (_c *MockStore_DeletePipeline_Call) Run(run func(pipeline *model.Pipeline)) *MockStore_DeletePipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Pipeline if args[0] != nil { arg0 = args[0].(*model.Pipeline) } run( arg0, ) }) return _c } func (_c *MockStore_DeletePipeline_Call) Return(err error) *MockStore_DeletePipeline_Call { _c.Call.Return(err) return _c } func (_c *MockStore_DeletePipeline_Call) RunAndReturn(run func(pipeline *model.Pipeline) error) *MockStore_DeletePipeline_Call { _c.Call.Return(run) return _c } // DeleteRepo provides a mock function for the type MockStore func (_mock *MockStore) DeleteRepo(repo *model.Repo) error { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for DeleteRepo") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo) error); ok { r0 = returnFunc(repo) } else { r0 = ret.Error(0) } return r0 } // MockStore_DeleteRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRepo' type MockStore_DeleteRepo_Call struct { *mock.Call } // DeleteRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockStore_Expecter) DeleteRepo(repo interface{}) *MockStore_DeleteRepo_Call { return &MockStore_DeleteRepo_Call{Call: _e.mock.On("DeleteRepo", repo)} } func (_c *MockStore_DeleteRepo_Call) Run(run func(repo *model.Repo)) *MockStore_DeleteRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockStore_DeleteRepo_Call) Return(err error) *MockStore_DeleteRepo_Call { _c.Call.Return(err) return _c } func (_c *MockStore_DeleteRepo_Call) RunAndReturn(run func(repo *model.Repo) error) *MockStore_DeleteRepo_Call { _c.Call.Return(run) return _c } // DeleteUser provides a mock function for the type MockStore func (_mock *MockStore) DeleteUser(user *model.User) error { ret := _mock.Called(user) if len(ret) == 0 { panic("no return value specified for DeleteUser") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.User) error); ok { r0 = returnFunc(user) } else { r0 = ret.Error(0) } return r0 } // MockStore_DeleteUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUser' type MockStore_DeleteUser_Call struct { *mock.Call } // DeleteUser is a helper method to define mock.On call // - user *model.User func (_e *MockStore_Expecter) DeleteUser(user interface{}) *MockStore_DeleteUser_Call { return &MockStore_DeleteUser_Call{Call: _e.mock.On("DeleteUser", user)} } func (_c *MockStore_DeleteUser_Call) Run(run func(user *model.User)) *MockStore_DeleteUser_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } run( arg0, ) }) return _c } func (_c *MockStore_DeleteUser_Call) Return(err error) *MockStore_DeleteUser_Call { _c.Call.Return(err) return _c } func (_c *MockStore_DeleteUser_Call) RunAndReturn(run func(user *model.User) error) *MockStore_DeleteUser_Call { _c.Call.Return(run) return _c } // ForgeCreate provides a mock function for the type MockStore func (_mock *MockStore) ForgeCreate(forge *model.Forge) error { ret := _mock.Called(forge) if len(ret) == 0 { panic("no return value specified for ForgeCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Forge) error); ok { r0 = returnFunc(forge) } else { r0 = ret.Error(0) } return r0 } // MockStore_ForgeCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeCreate' type MockStore_ForgeCreate_Call struct { *mock.Call } // ForgeCreate is a helper method to define mock.On call // - forge *model.Forge func (_e *MockStore_Expecter) ForgeCreate(forge interface{}) *MockStore_ForgeCreate_Call { return &MockStore_ForgeCreate_Call{Call: _e.mock.On("ForgeCreate", forge)} } func (_c *MockStore_ForgeCreate_Call) Run(run func(forge *model.Forge)) *MockStore_ForgeCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Forge if args[0] != nil { arg0 = args[0].(*model.Forge) } run( arg0, ) }) return _c } func (_c *MockStore_ForgeCreate_Call) Return(err error) *MockStore_ForgeCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_ForgeCreate_Call) RunAndReturn(run func(forge *model.Forge) error) *MockStore_ForgeCreate_Call { _c.Call.Return(run) return _c } // ForgeDelete provides a mock function for the type MockStore func (_mock *MockStore) ForgeDelete(forge *model.Forge) error { ret := _mock.Called(forge) if len(ret) == 0 { panic("no return value specified for ForgeDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Forge) error); ok { r0 = returnFunc(forge) } else { r0 = ret.Error(0) } return r0 } // MockStore_ForgeDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeDelete' type MockStore_ForgeDelete_Call struct { *mock.Call } // ForgeDelete is a helper method to define mock.On call // - forge *model.Forge func (_e *MockStore_Expecter) ForgeDelete(forge interface{}) *MockStore_ForgeDelete_Call { return &MockStore_ForgeDelete_Call{Call: _e.mock.On("ForgeDelete", forge)} } func (_c *MockStore_ForgeDelete_Call) Run(run func(forge *model.Forge)) *MockStore_ForgeDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Forge if args[0] != nil { arg0 = args[0].(*model.Forge) } run( arg0, ) }) return _c } func (_c *MockStore_ForgeDelete_Call) Return(err error) *MockStore_ForgeDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_ForgeDelete_Call) RunAndReturn(run func(forge *model.Forge) error) *MockStore_ForgeDelete_Call { _c.Call.Return(run) return _c } // ForgeGet provides a mock function for the type MockStore func (_mock *MockStore) ForgeGet(n int64) (*model.Forge, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for ForgeGet") } var r0 *model.Forge var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.Forge, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.Forge); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Forge) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_ForgeGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeGet' type MockStore_ForgeGet_Call struct { *mock.Call } // ForgeGet is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) ForgeGet(n interface{}) *MockStore_ForgeGet_Call { return &MockStore_ForgeGet_Call{Call: _e.mock.On("ForgeGet", n)} } func (_c *MockStore_ForgeGet_Call) Run(run func(n int64)) *MockStore_ForgeGet_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_ForgeGet_Call) Return(forge *model.Forge, err error) *MockStore_ForgeGet_Call { _c.Call.Return(forge, err) return _c } func (_c *MockStore_ForgeGet_Call) RunAndReturn(run func(n int64) (*model.Forge, error)) *MockStore_ForgeGet_Call { _c.Call.Return(run) return _c } // ForgeList provides a mock function for the type MockStore func (_mock *MockStore) ForgeList(p *model.ListOptions) ([]*model.Forge, error) { ret := _mock.Called(p) if len(ret) == 0 { panic("no return value specified for ForgeList") } var r0 []*model.Forge var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Forge, error)); ok { return returnFunc(p) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Forge); ok { r0 = returnFunc(p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Forge) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(p) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_ForgeList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeList' type MockStore_ForgeList_Call struct { *mock.Call } // ForgeList is a helper method to define mock.On call // - p *model.ListOptions func (_e *MockStore_Expecter) ForgeList(p interface{}) *MockStore_ForgeList_Call { return &MockStore_ForgeList_Call{Call: _e.mock.On("ForgeList", p)} } func (_c *MockStore_ForgeList_Call) Run(run func(p *model.ListOptions)) *MockStore_ForgeList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockStore_ForgeList_Call) Return(forges []*model.Forge, err error) *MockStore_ForgeList_Call { _c.Call.Return(forges, err) return _c } func (_c *MockStore_ForgeList_Call) RunAndReturn(run func(p *model.ListOptions) ([]*model.Forge, error)) *MockStore_ForgeList_Call { _c.Call.Return(run) return _c } // ForgeUpdate provides a mock function for the type MockStore func (_mock *MockStore) ForgeUpdate(forge *model.Forge) error { ret := _mock.Called(forge) if len(ret) == 0 { panic("no return value specified for ForgeUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Forge) error); ok { r0 = returnFunc(forge) } else { r0 = ret.Error(0) } return r0 } // MockStore_ForgeUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForgeUpdate' type MockStore_ForgeUpdate_Call struct { *mock.Call } // ForgeUpdate is a helper method to define mock.On call // - forge *model.Forge func (_e *MockStore_Expecter) ForgeUpdate(forge interface{}) *MockStore_ForgeUpdate_Call { return &MockStore_ForgeUpdate_Call{Call: _e.mock.On("ForgeUpdate", forge)} } func (_c *MockStore_ForgeUpdate_Call) Run(run func(forge *model.Forge)) *MockStore_ForgeUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Forge if args[0] != nil { arg0 = args[0].(*model.Forge) } run( arg0, ) }) return _c } func (_c *MockStore_ForgeUpdate_Call) Return(err error) *MockStore_ForgeUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_ForgeUpdate_Call) RunAndReturn(run func(forge *model.Forge) error) *MockStore_ForgeUpdate_Call { _c.Call.Return(run) return _c } // GetActivePipelineList provides a mock function for the type MockStore func (_mock *MockStore) GetActivePipelineList(repo *model.Repo) ([]*model.Pipeline, error) { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for GetActivePipelineList") } var r0 []*model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo) ([]*model.Pipeline, error)); ok { return returnFunc(repo) } if returnFunc, ok := ret.Get(0).(func(*model.Repo) []*model.Pipeline); ok { r0 = returnFunc(repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo) error); ok { r1 = returnFunc(repo) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetActivePipelineList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActivePipelineList' type MockStore_GetActivePipelineList_Call struct { *mock.Call } // GetActivePipelineList is a helper method to define mock.On call // - repo *model.Repo func (_e *MockStore_Expecter) GetActivePipelineList(repo interface{}) *MockStore_GetActivePipelineList_Call { return &MockStore_GetActivePipelineList_Call{Call: _e.mock.On("GetActivePipelineList", repo)} } func (_c *MockStore_GetActivePipelineList_Call) Run(run func(repo *model.Repo)) *MockStore_GetActivePipelineList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockStore_GetActivePipelineList_Call) Return(pipelines []*model.Pipeline, err error) *MockStore_GetActivePipelineList_Call { _c.Call.Return(pipelines, err) return _c } func (_c *MockStore_GetActivePipelineList_Call) RunAndReturn(run func(repo *model.Repo) ([]*model.Pipeline, error)) *MockStore_GetActivePipelineList_Call { _c.Call.Return(run) return _c } // GetPipeline provides a mock function for the type MockStore func (_mock *MockStore) GetPipeline(n int64) (*model.Pipeline, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for GetPipeline") } var r0 *model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.Pipeline, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.Pipeline); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipeline' type MockStore_GetPipeline_Call struct { *mock.Call } // GetPipeline is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) GetPipeline(n interface{}) *MockStore_GetPipeline_Call { return &MockStore_GetPipeline_Call{Call: _e.mock.On("GetPipeline", n)} } func (_c *MockStore_GetPipeline_Call) Run(run func(n int64)) *MockStore_GetPipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_GetPipeline_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipeline_Call { _c.Call.Return(pipeline, err) return _c } func (_c *MockStore_GetPipeline_Call) RunAndReturn(run func(n int64) (*model.Pipeline, error)) *MockStore_GetPipeline_Call { _c.Call.Return(run) return _c } // GetPipelineBadge provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineBadge(repo *model.Repo, s string, webhookEvents []model.WebhookEvent) (*model.Pipeline, error) { ret := _mock.Called(repo, s, webhookEvents) if len(ret) == 0 { panic("no return value specified for GetPipelineBadge") } var r0 *model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string, []model.WebhookEvent) (*model.Pipeline, error)); ok { return returnFunc(repo, s, webhookEvents) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string, []model.WebhookEvent) *model.Pipeline); ok { r0 = returnFunc(repo, s, webhookEvents) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string, []model.WebhookEvent) error); ok { r1 = returnFunc(repo, s, webhookEvents) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineBadge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineBadge' type MockStore_GetPipelineBadge_Call struct { *mock.Call } // GetPipelineBadge is a helper method to define mock.On call // - repo *model.Repo // - s string // - webhookEvents []model.WebhookEvent func (_e *MockStore_Expecter) GetPipelineBadge(repo interface{}, s interface{}, webhookEvents interface{}) *MockStore_GetPipelineBadge_Call { return &MockStore_GetPipelineBadge_Call{Call: _e.mock.On("GetPipelineBadge", repo, s, webhookEvents)} } func (_c *MockStore_GetPipelineBadge_Call) Run(run func(repo *model.Repo, s string, webhookEvents []model.WebhookEvent)) *MockStore_GetPipelineBadge_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 []model.WebhookEvent if args[2] != nil { arg2 = args[2].([]model.WebhookEvent) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockStore_GetPipelineBadge_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineBadge_Call { _c.Call.Return(pipeline, err) return _c } func (_c *MockStore_GetPipelineBadge_Call) RunAndReturn(run func(repo *model.Repo, s string, webhookEvents []model.WebhookEvent) (*model.Pipeline, error)) *MockStore_GetPipelineBadge_Call { _c.Call.Return(run) return _c } // GetPipelineCount provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineCount() (int64, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for GetPipelineCount") } var r0 int64 var r1 error if returnFunc, ok := ret.Get(0).(func() (int64, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() int64); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(int64) } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineCount' type MockStore_GetPipelineCount_Call struct { *mock.Call } // GetPipelineCount is a helper method to define mock.On call func (_e *MockStore_Expecter) GetPipelineCount() *MockStore_GetPipelineCount_Call { return &MockStore_GetPipelineCount_Call{Call: _e.mock.On("GetPipelineCount")} } func (_c *MockStore_GetPipelineCount_Call) Run(run func()) *MockStore_GetPipelineCount_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_GetPipelineCount_Call) Return(n int64, err error) *MockStore_GetPipelineCount_Call { _c.Call.Return(n, err) return _c } func (_c *MockStore_GetPipelineCount_Call) RunAndReturn(run func() (int64, error)) *MockStore_GetPipelineCount_Call { _c.Call.Return(run) return _c } // GetPipelineLastBefore provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineLastBefore(repo *model.Repo, s string, n int64) (*model.Pipeline, error) { ret := _mock.Called(repo, s, n) if len(ret) == 0 { panic("no return value specified for GetPipelineLastBefore") } var r0 *model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string, int64) (*model.Pipeline, error)); ok { return returnFunc(repo, s, n) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string, int64) *model.Pipeline); ok { r0 = returnFunc(repo, s, n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string, int64) error); ok { r1 = returnFunc(repo, s, n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineLastBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineLastBefore' type MockStore_GetPipelineLastBefore_Call struct { *mock.Call } // GetPipelineLastBefore is a helper method to define mock.On call // - repo *model.Repo // - s string // - n int64 func (_e *MockStore_Expecter) GetPipelineLastBefore(repo interface{}, s interface{}, n interface{}) *MockStore_GetPipelineLastBefore_Call { return &MockStore_GetPipelineLastBefore_Call{Call: _e.mock.On("GetPipelineLastBefore", repo, s, n)} } func (_c *MockStore_GetPipelineLastBefore_Call) Run(run func(repo *model.Repo, s string, n int64)) *MockStore_GetPipelineLastBefore_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } var arg2 int64 if args[2] != nil { arg2 = args[2].(int64) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockStore_GetPipelineLastBefore_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineLastBefore_Call { _c.Call.Return(pipeline, err) return _c } func (_c *MockStore_GetPipelineLastBefore_Call) RunAndReturn(run func(repo *model.Repo, s string, n int64) (*model.Pipeline, error)) *MockStore_GetPipelineLastBefore_Call { _c.Call.Return(run) return _c } // GetPipelineLastByBranch provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineLastByBranch(repo *model.Repo, s string) (*model.Pipeline, error) { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for GetPipelineLastByBranch") } var r0 *model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Pipeline, error)); ok { return returnFunc(repo, s) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Pipeline); ok { r0 = returnFunc(repo, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok { r1 = returnFunc(repo, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineLastByBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineLastByBranch' type MockStore_GetPipelineLastByBranch_Call struct { *mock.Call } // GetPipelineLastByBranch is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockStore_Expecter) GetPipelineLastByBranch(repo interface{}, s interface{}) *MockStore_GetPipelineLastByBranch_Call { return &MockStore_GetPipelineLastByBranch_Call{Call: _e.mock.On("GetPipelineLastByBranch", repo, s)} } func (_c *MockStore_GetPipelineLastByBranch_Call) Run(run func(repo *model.Repo, s string)) *MockStore_GetPipelineLastByBranch_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_GetPipelineLastByBranch_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineLastByBranch_Call { _c.Call.Return(pipeline, err) return _c } func (_c *MockStore_GetPipelineLastByBranch_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Pipeline, error)) *MockStore_GetPipelineLastByBranch_Call { _c.Call.Return(run) return _c } // GetPipelineList provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineList(repo *model.Repo, listOptions *model.ListOptions, pipelineFilter *model.PipelineFilter) ([]*model.Pipeline, error) { ret := _mock.Called(repo, listOptions, pipelineFilter) if len(ret) == 0 { panic("no return value specified for GetPipelineList") } var r0 []*model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions, *model.PipelineFilter) ([]*model.Pipeline, error)); ok { return returnFunc(repo, listOptions, pipelineFilter) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.ListOptions, *model.PipelineFilter) []*model.Pipeline); ok { r0 = returnFunc(repo, listOptions, pipelineFilter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.ListOptions, *model.PipelineFilter) error); ok { r1 = returnFunc(repo, listOptions, pipelineFilter) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineList' type MockStore_GetPipelineList_Call struct { *mock.Call } // GetPipelineList is a helper method to define mock.On call // - repo *model.Repo // - listOptions *model.ListOptions // - pipelineFilter *model.PipelineFilter func (_e *MockStore_Expecter) GetPipelineList(repo interface{}, listOptions interface{}, pipelineFilter interface{}) *MockStore_GetPipelineList_Call { return &MockStore_GetPipelineList_Call{Call: _e.mock.On("GetPipelineList", repo, listOptions, pipelineFilter)} } func (_c *MockStore_GetPipelineList_Call) Run(run func(repo *model.Repo, listOptions *model.ListOptions, pipelineFilter *model.PipelineFilter)) *MockStore_GetPipelineList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } var arg2 *model.PipelineFilter if args[2] != nil { arg2 = args[2].(*model.PipelineFilter) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockStore_GetPipelineList_Call) Return(pipelines []*model.Pipeline, err error) *MockStore_GetPipelineList_Call { _c.Call.Return(pipelines, err) return _c } func (_c *MockStore_GetPipelineList_Call) RunAndReturn(run func(repo *model.Repo, listOptions *model.ListOptions, pipelineFilter *model.PipelineFilter) ([]*model.Pipeline, error)) *MockStore_GetPipelineList_Call { _c.Call.Return(run) return _c } // GetPipelineNumber provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineNumber(repo *model.Repo, n int64) (*model.Pipeline, error) { ret := _mock.Called(repo, n) if len(ret) == 0 { panic("no return value specified for GetPipelineNumber") } var r0 *model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) (*model.Pipeline, error)); ok { return returnFunc(repo, n) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, int64) *model.Pipeline); ok { r0 = returnFunc(repo, n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, int64) error); ok { r1 = returnFunc(repo, n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineNumber_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineNumber' type MockStore_GetPipelineNumber_Call struct { *mock.Call } // GetPipelineNumber is a helper method to define mock.On call // - repo *model.Repo // - n int64 func (_e *MockStore_Expecter) GetPipelineNumber(repo interface{}, n interface{}) *MockStore_GetPipelineNumber_Call { return &MockStore_GetPipelineNumber_Call{Call: _e.mock.On("GetPipelineNumber", repo, n)} } func (_c *MockStore_GetPipelineNumber_Call) Run(run func(repo *model.Repo, n int64)) *MockStore_GetPipelineNumber_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_GetPipelineNumber_Call) Return(pipeline *model.Pipeline, err error) *MockStore_GetPipelineNumber_Call { _c.Call.Return(pipeline, err) return _c } func (_c *MockStore_GetPipelineNumber_Call) RunAndReturn(run func(repo *model.Repo, n int64) (*model.Pipeline, error)) *MockStore_GetPipelineNumber_Call { _c.Call.Return(run) return _c } // GetPipelineQueue provides a mock function for the type MockStore func (_mock *MockStore) GetPipelineQueue() ([]*model.Feed, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for GetPipelineQueue") } var r0 []*model.Feed var r1 error if returnFunc, ok := ret.Get(0).(func() ([]*model.Feed, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() []*model.Feed); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Feed) } } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetPipelineQueue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineQueue' type MockStore_GetPipelineQueue_Call struct { *mock.Call } // GetPipelineQueue is a helper method to define mock.On call func (_e *MockStore_Expecter) GetPipelineQueue() *MockStore_GetPipelineQueue_Call { return &MockStore_GetPipelineQueue_Call{Call: _e.mock.On("GetPipelineQueue")} } func (_c *MockStore_GetPipelineQueue_Call) Run(run func()) *MockStore_GetPipelineQueue_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_GetPipelineQueue_Call) Return(feeds []*model.Feed, err error) *MockStore_GetPipelineQueue_Call { _c.Call.Return(feeds, err) return _c } func (_c *MockStore_GetPipelineQueue_Call) RunAndReturn(run func() ([]*model.Feed, error)) *MockStore_GetPipelineQueue_Call { _c.Call.Return(run) return _c } // GetRepo provides a mock function for the type MockStore func (_mock *MockStore) GetRepo(n int64) (*model.Repo, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for GetRepo") } var r0 *model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.Repo, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.Repo); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepo' type MockStore_GetRepo_Call struct { *mock.Call } // GetRepo is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) GetRepo(n interface{}) *MockStore_GetRepo_Call { return &MockStore_GetRepo_Call{Call: _e.mock.On("GetRepo", n)} } func (_c *MockStore_GetRepo_Call) Run(run func(n int64)) *MockStore_GetRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_GetRepo_Call) Return(repo *model.Repo, err error) *MockStore_GetRepo_Call { _c.Call.Return(repo, err) return _c } func (_c *MockStore_GetRepo_Call) RunAndReturn(run func(n int64) (*model.Repo, error)) *MockStore_GetRepo_Call { _c.Call.Return(run) return _c } // GetRepoCount provides a mock function for the type MockStore func (_mock *MockStore) GetRepoCount() (int64, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for GetRepoCount") } var r0 int64 var r1 error if returnFunc, ok := ret.Get(0).(func() (int64, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() int64); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(int64) } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetRepoCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoCount' type MockStore_GetRepoCount_Call struct { *mock.Call } // GetRepoCount is a helper method to define mock.On call func (_e *MockStore_Expecter) GetRepoCount() *MockStore_GetRepoCount_Call { return &MockStore_GetRepoCount_Call{Call: _e.mock.On("GetRepoCount")} } func (_c *MockStore_GetRepoCount_Call) Run(run func()) *MockStore_GetRepoCount_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_GetRepoCount_Call) Return(n int64, err error) *MockStore_GetRepoCount_Call { _c.Call.Return(n, err) return _c } func (_c *MockStore_GetRepoCount_Call) RunAndReturn(run func() (int64, error)) *MockStore_GetRepoCount_Call { _c.Call.Return(run) return _c } // GetRepoForgeID provides a mock function for the type MockStore func (_mock *MockStore) GetRepoForgeID(n int64, forgeRemoteID model.ForgeRemoteID) (*model.Repo, error) { ret := _mock.Called(n, forgeRemoteID) if len(ret) == 0 { panic("no return value specified for GetRepoForgeID") } var r0 *model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) (*model.Repo, error)); ok { return returnFunc(n, forgeRemoteID) } if returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) *model.Repo); ok { r0 = returnFunc(n, forgeRemoteID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(int64, model.ForgeRemoteID) error); ok { r1 = returnFunc(n, forgeRemoteID) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetRepoForgeID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoForgeID' type MockStore_GetRepoForgeID_Call struct { *mock.Call } // GetRepoForgeID is a helper method to define mock.On call // - n int64 // - forgeRemoteID model.ForgeRemoteID func (_e *MockStore_Expecter) GetRepoForgeID(n interface{}, forgeRemoteID interface{}) *MockStore_GetRepoForgeID_Call { return &MockStore_GetRepoForgeID_Call{Call: _e.mock.On("GetRepoForgeID", n, forgeRemoteID)} } func (_c *MockStore_GetRepoForgeID_Call) Run(run func(n int64, forgeRemoteID model.ForgeRemoteID)) *MockStore_GetRepoForgeID_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 model.ForgeRemoteID if args[1] != nil { arg1 = args[1].(model.ForgeRemoteID) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_GetRepoForgeID_Call) Return(repo *model.Repo, err error) *MockStore_GetRepoForgeID_Call { _c.Call.Return(repo, err) return _c } func (_c *MockStore_GetRepoForgeID_Call) RunAndReturn(run func(n int64, forgeRemoteID model.ForgeRemoteID) (*model.Repo, error)) *MockStore_GetRepoForgeID_Call { _c.Call.Return(run) return _c } // GetRepoLatestPipelines provides a mock function for the type MockStore func (_mock *MockStore) GetRepoLatestPipelines(int64s []int64) ([]*model.Pipeline, error) { ret := _mock.Called(int64s) if len(ret) == 0 { panic("no return value specified for GetRepoLatestPipelines") } var r0 []*model.Pipeline var r1 error if returnFunc, ok := ret.Get(0).(func([]int64) ([]*model.Pipeline, error)); ok { return returnFunc(int64s) } if returnFunc, ok := ret.Get(0).(func([]int64) []*model.Pipeline); ok { r0 = returnFunc(int64s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Pipeline) } } if returnFunc, ok := ret.Get(1).(func([]int64) error); ok { r1 = returnFunc(int64s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetRepoLatestPipelines_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoLatestPipelines' type MockStore_GetRepoLatestPipelines_Call struct { *mock.Call } // GetRepoLatestPipelines is a helper method to define mock.On call // - int64s []int64 func (_e *MockStore_Expecter) GetRepoLatestPipelines(int64s interface{}) *MockStore_GetRepoLatestPipelines_Call { return &MockStore_GetRepoLatestPipelines_Call{Call: _e.mock.On("GetRepoLatestPipelines", int64s)} } func (_c *MockStore_GetRepoLatestPipelines_Call) Run(run func(int64s []int64)) *MockStore_GetRepoLatestPipelines_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 []int64 if args[0] != nil { arg0 = args[0].([]int64) } run( arg0, ) }) return _c } func (_c *MockStore_GetRepoLatestPipelines_Call) Return(pipelines []*model.Pipeline, err error) *MockStore_GetRepoLatestPipelines_Call { _c.Call.Return(pipelines, err) return _c } func (_c *MockStore_GetRepoLatestPipelines_Call) RunAndReturn(run func(int64s []int64) ([]*model.Pipeline, error)) *MockStore_GetRepoLatestPipelines_Call { _c.Call.Return(run) return _c } // GetRepoName provides a mock function for the type MockStore func (_mock *MockStore) GetRepoName(s string) (*model.Repo, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GetRepoName") } var r0 *model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Repo, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Repo); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetRepoName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoName' type MockStore_GetRepoName_Call struct { *mock.Call } // GetRepoName is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) GetRepoName(s interface{}) *MockStore_GetRepoName_Call { return &MockStore_GetRepoName_Call{Call: _e.mock.On("GetRepoName", s)} } func (_c *MockStore_GetRepoName_Call) Run(run func(s string)) *MockStore_GetRepoName_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_GetRepoName_Call) Return(repo *model.Repo, err error) *MockStore_GetRepoName_Call { _c.Call.Return(repo, err) return _c } func (_c *MockStore_GetRepoName_Call) RunAndReturn(run func(s string) (*model.Repo, error)) *MockStore_GetRepoName_Call { _c.Call.Return(run) return _c } // GetRepoNameFallback provides a mock function for the type MockStore func (_mock *MockStore) GetRepoNameFallback(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) { ret := _mock.Called(forgeID, remoteID, fullName) if len(ret) == 0 { panic("no return value specified for GetRepoNameFallback") } var r0 *model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID, string) (*model.Repo, error)); ok { return returnFunc(forgeID, remoteID, fullName) } if returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID, string) *model.Repo); ok { r0 = returnFunc(forgeID, remoteID, fullName) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(int64, model.ForgeRemoteID, string) error); ok { r1 = returnFunc(forgeID, remoteID, fullName) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetRepoNameFallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoNameFallback' type MockStore_GetRepoNameFallback_Call struct { *mock.Call } // GetRepoNameFallback is a helper method to define mock.On call // - forgeID int64 // - remoteID model.ForgeRemoteID // - fullName string func (_e *MockStore_Expecter) GetRepoNameFallback(forgeID interface{}, remoteID interface{}, fullName interface{}) *MockStore_GetRepoNameFallback_Call { return &MockStore_GetRepoNameFallback_Call{Call: _e.mock.On("GetRepoNameFallback", forgeID, remoteID, fullName)} } func (_c *MockStore_GetRepoNameFallback_Call) Run(run func(forgeID int64, remoteID model.ForgeRemoteID, fullName string)) *MockStore_GetRepoNameFallback_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 model.ForgeRemoteID if args[1] != nil { arg1 = args[1].(model.ForgeRemoteID) } var arg2 string if args[2] != nil { arg2 = args[2].(string) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockStore_GetRepoNameFallback_Call) Return(repo *model.Repo, err error) *MockStore_GetRepoNameFallback_Call { _c.Call.Return(repo, err) return _c } func (_c *MockStore_GetRepoNameFallback_Call) RunAndReturn(run func(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error)) *MockStore_GetRepoNameFallback_Call { _c.Call.Return(run) return _c } // GetUser provides a mock function for the type MockStore func (_mock *MockStore) GetUser(n int64) (*model.User, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for GetUser") } var r0 *model.User var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.User, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.User); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.User) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUser' type MockStore_GetUser_Call struct { *mock.Call } // GetUser is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) GetUser(n interface{}) *MockStore_GetUser_Call { return &MockStore_GetUser_Call{Call: _e.mock.On("GetUser", n)} } func (_c *MockStore_GetUser_Call) Run(run func(n int64)) *MockStore_GetUser_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_GetUser_Call) Return(user *model.User, err error) *MockStore_GetUser_Call { _c.Call.Return(user, err) return _c } func (_c *MockStore_GetUser_Call) RunAndReturn(run func(n int64) (*model.User, error)) *MockStore_GetUser_Call { _c.Call.Return(run) return _c } // GetUserByLogin provides a mock function for the type MockStore func (_mock *MockStore) GetUserByLogin(n int64, s string) (*model.User, error) { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for GetUserByLogin") } var r0 *model.User var r1 error if returnFunc, ok := ret.Get(0).(func(int64, string) (*model.User, error)); ok { return returnFunc(n, s) } if returnFunc, ok := ret.Get(0).(func(int64, string) *model.User); ok { r0 = returnFunc(n, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.User) } } if returnFunc, ok := ret.Get(1).(func(int64, string) error); ok { r1 = returnFunc(n, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetUserByLogin_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByLogin' type MockStore_GetUserByLogin_Call struct { *mock.Call } // GetUserByLogin is a helper method to define mock.On call // - n int64 // - s string func (_e *MockStore_Expecter) GetUserByLogin(n interface{}, s interface{}) *MockStore_GetUserByLogin_Call { return &MockStore_GetUserByLogin_Call{Call: _e.mock.On("GetUserByLogin", n, s)} } func (_c *MockStore_GetUserByLogin_Call) Run(run func(n int64, s string)) *MockStore_GetUserByLogin_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_GetUserByLogin_Call) Return(user *model.User, err error) *MockStore_GetUserByLogin_Call { _c.Call.Return(user, err) return _c } func (_c *MockStore_GetUserByLogin_Call) RunAndReturn(run func(n int64, s string) (*model.User, error)) *MockStore_GetUserByLogin_Call { _c.Call.Return(run) return _c } // GetUserByRemoteID provides a mock function for the type MockStore func (_mock *MockStore) GetUserByRemoteID(n int64, forgeRemoteID model.ForgeRemoteID) (*model.User, error) { ret := _mock.Called(n, forgeRemoteID) if len(ret) == 0 { panic("no return value specified for GetUserByRemoteID") } var r0 *model.User var r1 error if returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) (*model.User, error)); ok { return returnFunc(n, forgeRemoteID) } if returnFunc, ok := ret.Get(0).(func(int64, model.ForgeRemoteID) *model.User); ok { r0 = returnFunc(n, forgeRemoteID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.User) } } if returnFunc, ok := ret.Get(1).(func(int64, model.ForgeRemoteID) error); ok { r1 = returnFunc(n, forgeRemoteID) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetUserByRemoteID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByRemoteID' type MockStore_GetUserByRemoteID_Call struct { *mock.Call } // GetUserByRemoteID is a helper method to define mock.On call // - n int64 // - forgeRemoteID model.ForgeRemoteID func (_e *MockStore_Expecter) GetUserByRemoteID(n interface{}, forgeRemoteID interface{}) *MockStore_GetUserByRemoteID_Call { return &MockStore_GetUserByRemoteID_Call{Call: _e.mock.On("GetUserByRemoteID", n, forgeRemoteID)} } func (_c *MockStore_GetUserByRemoteID_Call) Run(run func(n int64, forgeRemoteID model.ForgeRemoteID)) *MockStore_GetUserByRemoteID_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 model.ForgeRemoteID if args[1] != nil { arg1 = args[1].(model.ForgeRemoteID) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_GetUserByRemoteID_Call) Return(user *model.User, err error) *MockStore_GetUserByRemoteID_Call { _c.Call.Return(user, err) return _c } func (_c *MockStore_GetUserByRemoteID_Call) RunAndReturn(run func(n int64, forgeRemoteID model.ForgeRemoteID) (*model.User, error)) *MockStore_GetUserByRemoteID_Call { _c.Call.Return(run) return _c } // GetUserCount provides a mock function for the type MockStore func (_mock *MockStore) GetUserCount() (int64, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for GetUserCount") } var r0 int64 var r1 error if returnFunc, ok := ret.Get(0).(func() (int64, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() int64); ok { r0 = returnFunc() } else { r0 = ret.Get(0).(int64) } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetUserCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserCount' type MockStore_GetUserCount_Call struct { *mock.Call } // GetUserCount is a helper method to define mock.On call func (_e *MockStore_Expecter) GetUserCount() *MockStore_GetUserCount_Call { return &MockStore_GetUserCount_Call{Call: _e.mock.On("GetUserCount")} } func (_c *MockStore_GetUserCount_Call) Run(run func()) *MockStore_GetUserCount_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_GetUserCount_Call) Return(n int64, err error) *MockStore_GetUserCount_Call { _c.Call.Return(n, err) return _c } func (_c *MockStore_GetUserCount_Call) RunAndReturn(run func() (int64, error)) *MockStore_GetUserCount_Call { _c.Call.Return(run) return _c } // GetUserList provides a mock function for the type MockStore func (_mock *MockStore) GetUserList(p *model.ListOptions) ([]*model.User, error) { ret := _mock.Called(p) if len(ret) == 0 { panic("no return value specified for GetUserList") } var r0 []*model.User var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.User, error)); ok { return returnFunc(p) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.User); ok { r0 = returnFunc(p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.User) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(p) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GetUserList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserList' type MockStore_GetUserList_Call struct { *mock.Call } // GetUserList is a helper method to define mock.On call // - p *model.ListOptions func (_e *MockStore_Expecter) GetUserList(p interface{}) *MockStore_GetUserList_Call { return &MockStore_GetUserList_Call{Call: _e.mock.On("GetUserList", p)} } func (_c *MockStore_GetUserList_Call) Run(run func(p *model.ListOptions)) *MockStore_GetUserList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockStore_GetUserList_Call) Return(users []*model.User, err error) *MockStore_GetUserList_Call { _c.Call.Return(users, err) return _c } func (_c *MockStore_GetUserList_Call) RunAndReturn(run func(p *model.ListOptions) ([]*model.User, error)) *MockStore_GetUserList_Call { _c.Call.Return(run) return _c } // GlobalRegistryFind provides a mock function for the type MockStore func (_mock *MockStore) GlobalRegistryFind(s string) (*model.Registry, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalRegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Registry, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Registry); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GlobalRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryFind' type MockStore_GlobalRegistryFind_Call struct { *mock.Call } // GlobalRegistryFind is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) GlobalRegistryFind(s interface{}) *MockStore_GlobalRegistryFind_Call { return &MockStore_GlobalRegistryFind_Call{Call: _e.mock.On("GlobalRegistryFind", s)} } func (_c *MockStore_GlobalRegistryFind_Call) Run(run func(s string)) *MockStore_GlobalRegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_GlobalRegistryFind_Call) Return(registry *model.Registry, err error) *MockStore_GlobalRegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockStore_GlobalRegistryFind_Call) RunAndReturn(run func(s string) (*model.Registry, error)) *MockStore_GlobalRegistryFind_Call { _c.Call.Return(run) return _c } // GlobalRegistryList provides a mock function for the type MockStore func (_mock *MockStore) GlobalRegistryList(listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(listOptions) if len(ret) == 0 { panic("no return value specified for GlobalRegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Registry); ok { r0 = returnFunc(listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GlobalRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalRegistryList' type MockStore_GlobalRegistryList_Call struct { *mock.Call } // GlobalRegistryList is a helper method to define mock.On call // - listOptions *model.ListOptions func (_e *MockStore_Expecter) GlobalRegistryList(listOptions interface{}) *MockStore_GlobalRegistryList_Call { return &MockStore_GlobalRegistryList_Call{Call: _e.mock.On("GlobalRegistryList", listOptions)} } func (_c *MockStore_GlobalRegistryList_Call) Run(run func(listOptions *model.ListOptions)) *MockStore_GlobalRegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockStore_GlobalRegistryList_Call) Return(registrys []*model.Registry, err error) *MockStore_GlobalRegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockStore_GlobalRegistryList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Registry, error)) *MockStore_GlobalRegistryList_Call { _c.Call.Return(run) return _c } // GlobalSecretFind provides a mock function for the type MockStore func (_mock *MockStore) GlobalSecretFind(s string) (*model.Secret, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for GlobalSecretFind") } var r0 *model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Secret, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) *model.Secret); ok { r0 = returnFunc(s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GlobalSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretFind' type MockStore_GlobalSecretFind_Call struct { *mock.Call } // GlobalSecretFind is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) GlobalSecretFind(s interface{}) *MockStore_GlobalSecretFind_Call { return &MockStore_GlobalSecretFind_Call{Call: _e.mock.On("GlobalSecretFind", s)} } func (_c *MockStore_GlobalSecretFind_Call) Run(run func(s string)) *MockStore_GlobalSecretFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_GlobalSecretFind_Call) Return(secret *model.Secret, err error) *MockStore_GlobalSecretFind_Call { _c.Call.Return(secret, err) return _c } func (_c *MockStore_GlobalSecretFind_Call) RunAndReturn(run func(s string) (*model.Secret, error)) *MockStore_GlobalSecretFind_Call { _c.Call.Return(run) return _c } // GlobalSecretList provides a mock function for the type MockStore func (_mock *MockStore) GlobalSecretList(listOptions *model.ListOptions) ([]*model.Secret, error) { ret := _mock.Called(listOptions) if len(ret) == 0 { panic("no return value specified for GlobalSecretList") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Secret, error)); ok { return returnFunc(listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Secret); ok { r0 = returnFunc(listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_GlobalSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GlobalSecretList' type MockStore_GlobalSecretList_Call struct { *mock.Call } // GlobalSecretList is a helper method to define mock.On call // - listOptions *model.ListOptions func (_e *MockStore_Expecter) GlobalSecretList(listOptions interface{}) *MockStore_GlobalSecretList_Call { return &MockStore_GlobalSecretList_Call{Call: _e.mock.On("GlobalSecretList", listOptions)} } func (_c *MockStore_GlobalSecretList_Call) Run(run func(listOptions *model.ListOptions)) *MockStore_GlobalSecretList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockStore_GlobalSecretList_Call) Return(secrets []*model.Secret, err error) *MockStore_GlobalSecretList_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockStore_GlobalSecretList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Secret, error)) *MockStore_GlobalSecretList_Call { _c.Call.Return(run) return _c } // HasRedirectionForRepo provides a mock function for the type MockStore func (_mock *MockStore) HasRedirectionForRepo(n int64, s string) (bool, error) { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for HasRedirectionForRepo") } var r0 bool var r1 error if returnFunc, ok := ret.Get(0).(func(int64, string) (bool, error)); ok { return returnFunc(n, s) } if returnFunc, ok := ret.Get(0).(func(int64, string) bool); ok { r0 = returnFunc(n, s) } else { r0 = ret.Get(0).(bool) } if returnFunc, ok := ret.Get(1).(func(int64, string) error); ok { r1 = returnFunc(n, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_HasRedirectionForRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasRedirectionForRepo' type MockStore_HasRedirectionForRepo_Call struct { *mock.Call } // HasRedirectionForRepo is a helper method to define mock.On call // - n int64 // - s string func (_e *MockStore_Expecter) HasRedirectionForRepo(n interface{}, s interface{}) *MockStore_HasRedirectionForRepo_Call { return &MockStore_HasRedirectionForRepo_Call{Call: _e.mock.On("HasRedirectionForRepo", n, s)} } func (_c *MockStore_HasRedirectionForRepo_Call) Run(run func(n int64, s string)) *MockStore_HasRedirectionForRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_HasRedirectionForRepo_Call) Return(b bool, err error) *MockStore_HasRedirectionForRepo_Call { _c.Call.Return(b, err) return _c } func (_c *MockStore_HasRedirectionForRepo_Call) RunAndReturn(run func(n int64, s string) (bool, error)) *MockStore_HasRedirectionForRepo_Call { _c.Call.Return(run) return _c } // LogAppend provides a mock function for the type MockStore func (_mock *MockStore) LogAppend(step *model.Step, logEntrys []*model.LogEntry) error { ret := _mock.Called(step, logEntrys) if len(ret) == 0 { panic("no return value specified for LogAppend") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Step, []*model.LogEntry) error); ok { r0 = returnFunc(step, logEntrys) } else { r0 = ret.Error(0) } return r0 } // MockStore_LogAppend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogAppend' type MockStore_LogAppend_Call struct { *mock.Call } // LogAppend is a helper method to define mock.On call // - step *model.Step // - logEntrys []*model.LogEntry func (_e *MockStore_Expecter) LogAppend(step interface{}, logEntrys interface{}) *MockStore_LogAppend_Call { return &MockStore_LogAppend_Call{Call: _e.mock.On("LogAppend", step, logEntrys)} } func (_c *MockStore_LogAppend_Call) Run(run func(step *model.Step, logEntrys []*model.LogEntry)) *MockStore_LogAppend_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } var arg1 []*model.LogEntry if args[1] != nil { arg1 = args[1].([]*model.LogEntry) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_LogAppend_Call) Return(err error) *MockStore_LogAppend_Call { _c.Call.Return(err) return _c } func (_c *MockStore_LogAppend_Call) RunAndReturn(run func(step *model.Step, logEntrys []*model.LogEntry) error) *MockStore_LogAppend_Call { _c.Call.Return(run) return _c } // LogDelete provides a mock function for the type MockStore func (_mock *MockStore) LogDelete(step *model.Step) error { ret := _mock.Called(step) if len(ret) == 0 { panic("no return value specified for LogDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Step) error); ok { r0 = returnFunc(step) } else { r0 = ret.Error(0) } return r0 } // MockStore_LogDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogDelete' type MockStore_LogDelete_Call struct { *mock.Call } // LogDelete is a helper method to define mock.On call // - step *model.Step func (_e *MockStore_Expecter) LogDelete(step interface{}) *MockStore_LogDelete_Call { return &MockStore_LogDelete_Call{Call: _e.mock.On("LogDelete", step)} } func (_c *MockStore_LogDelete_Call) Run(run func(step *model.Step)) *MockStore_LogDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockStore_LogDelete_Call) Return(err error) *MockStore_LogDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_LogDelete_Call) RunAndReturn(run func(step *model.Step) error) *MockStore_LogDelete_Call { _c.Call.Return(run) return _c } // LogFind provides a mock function for the type MockStore func (_mock *MockStore) LogFind(step *model.Step) ([]*model.LogEntry, error) { ret := _mock.Called(step) if len(ret) == 0 { panic("no return value specified for LogFind") } var r0 []*model.LogEntry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Step) ([]*model.LogEntry, error)); ok { return returnFunc(step) } if returnFunc, ok := ret.Get(0).(func(*model.Step) []*model.LogEntry); ok { r0 = returnFunc(step) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.LogEntry) } } if returnFunc, ok := ret.Get(1).(func(*model.Step) error); ok { r1 = returnFunc(step) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_LogFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LogFind' type MockStore_LogFind_Call struct { *mock.Call } // LogFind is a helper method to define mock.On call // - step *model.Step func (_e *MockStore_Expecter) LogFind(step interface{}) *MockStore_LogFind_Call { return &MockStore_LogFind_Call{Call: _e.mock.On("LogFind", step)} } func (_c *MockStore_LogFind_Call) Run(run func(step *model.Step)) *MockStore_LogFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockStore_LogFind_Call) Return(logEntrys []*model.LogEntry, err error) *MockStore_LogFind_Call { _c.Call.Return(logEntrys, err) return _c } func (_c *MockStore_LogFind_Call) RunAndReturn(run func(step *model.Step) ([]*model.LogEntry, error)) *MockStore_LogFind_Call { _c.Call.Return(run) return _c } // Migrate provides a mock function for the type MockStore func (_mock *MockStore) Migrate(context1 context.Context, b bool) error { ret := _mock.Called(context1, b) if len(ret) == 0 { panic("no return value specified for Migrate") } var r0 error if returnFunc, ok := ret.Get(0).(func(context.Context, bool) error); ok { r0 = returnFunc(context1, b) } else { r0 = ret.Error(0) } return r0 } // MockStore_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate' type MockStore_Migrate_Call struct { *mock.Call } // Migrate is a helper method to define mock.On call // - context1 context.Context // - b bool func (_e *MockStore_Expecter) Migrate(context1 interface{}, b interface{}) *MockStore_Migrate_Call { return &MockStore_Migrate_Call{Call: _e.mock.On("Migrate", context1, b)} } func (_c *MockStore_Migrate_Call) Run(run func(context1 context.Context, b bool)) *MockStore_Migrate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 context.Context if args[0] != nil { arg0 = args[0].(context.Context) } var arg1 bool if args[1] != nil { arg1 = args[1].(bool) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_Migrate_Call) Return(err error) *MockStore_Migrate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_Migrate_Call) RunAndReturn(run func(context1 context.Context, b bool) error) *MockStore_Migrate_Call { _c.Call.Return(run) return _c } // OrgCreate provides a mock function for the type MockStore func (_mock *MockStore) OrgCreate(org *model.Org) error { ret := _mock.Called(org) if len(ret) == 0 { panic("no return value specified for OrgCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Org) error); ok { r0 = returnFunc(org) } else { r0 = ret.Error(0) } return r0 } // MockStore_OrgCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgCreate' type MockStore_OrgCreate_Call struct { *mock.Call } // OrgCreate is a helper method to define mock.On call // - org *model.Org func (_e *MockStore_Expecter) OrgCreate(org interface{}) *MockStore_OrgCreate_Call { return &MockStore_OrgCreate_Call{Call: _e.mock.On("OrgCreate", org)} } func (_c *MockStore_OrgCreate_Call) Run(run func(org *model.Org)) *MockStore_OrgCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Org if args[0] != nil { arg0 = args[0].(*model.Org) } run( arg0, ) }) return _c } func (_c *MockStore_OrgCreate_Call) Return(err error) *MockStore_OrgCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_OrgCreate_Call) RunAndReturn(run func(org *model.Org) error) *MockStore_OrgCreate_Call { _c.Call.Return(run) return _c } // OrgDelete provides a mock function for the type MockStore func (_mock *MockStore) OrgDelete(n int64) error { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for OrgDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64) error); ok { r0 = returnFunc(n) } else { r0 = ret.Error(0) } return r0 } // MockStore_OrgDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgDelete' type MockStore_OrgDelete_Call struct { *mock.Call } // OrgDelete is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) OrgDelete(n interface{}) *MockStore_OrgDelete_Call { return &MockStore_OrgDelete_Call{Call: _e.mock.On("OrgDelete", n)} } func (_c *MockStore_OrgDelete_Call) Run(run func(n int64)) *MockStore_OrgDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_OrgDelete_Call) Return(err error) *MockStore_OrgDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_OrgDelete_Call) RunAndReturn(run func(n int64) error) *MockStore_OrgDelete_Call { _c.Call.Return(run) return _c } // OrgFindByName provides a mock function for the type MockStore func (_mock *MockStore) OrgFindByName(s string, n int64) (*model.Org, error) { ret := _mock.Called(s, n) if len(ret) == 0 { panic("no return value specified for OrgFindByName") } var r0 *model.Org var r1 error if returnFunc, ok := ret.Get(0).(func(string, int64) (*model.Org, error)); ok { return returnFunc(s, n) } if returnFunc, ok := ret.Get(0).(func(string, int64) *model.Org); ok { r0 = returnFunc(s, n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Org) } } if returnFunc, ok := ret.Get(1).(func(string, int64) error); ok { r1 = returnFunc(s, n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgFindByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgFindByName' type MockStore_OrgFindByName_Call struct { *mock.Call } // OrgFindByName is a helper method to define mock.On call // - s string // - n int64 func (_e *MockStore_Expecter) OrgFindByName(s interface{}, n interface{}) *MockStore_OrgFindByName_Call { return &MockStore_OrgFindByName_Call{Call: _e.mock.On("OrgFindByName", s, n)} } func (_c *MockStore_OrgFindByName_Call) Run(run func(s string, n int64)) *MockStore_OrgFindByName_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_OrgFindByName_Call) Return(org *model.Org, err error) *MockStore_OrgFindByName_Call { _c.Call.Return(org, err) return _c } func (_c *MockStore_OrgFindByName_Call) RunAndReturn(run func(s string, n int64) (*model.Org, error)) *MockStore_OrgFindByName_Call { _c.Call.Return(run) return _c } // OrgGet provides a mock function for the type MockStore func (_mock *MockStore) OrgGet(n int64) (*model.Org, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for OrgGet") } var r0 *model.Org var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.Org, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.Org); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Org) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgGet' type MockStore_OrgGet_Call struct { *mock.Call } // OrgGet is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) OrgGet(n interface{}) *MockStore_OrgGet_Call { return &MockStore_OrgGet_Call{Call: _e.mock.On("OrgGet", n)} } func (_c *MockStore_OrgGet_Call) Run(run func(n int64)) *MockStore_OrgGet_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_OrgGet_Call) Return(org *model.Org, err error) *MockStore_OrgGet_Call { _c.Call.Return(org, err) return _c } func (_c *MockStore_OrgGet_Call) RunAndReturn(run func(n int64) (*model.Org, error)) *MockStore_OrgGet_Call { _c.Call.Return(run) return _c } // OrgList provides a mock function for the type MockStore func (_mock *MockStore) OrgList(listOptions *model.ListOptions) ([]*model.Org, error) { ret := _mock.Called(listOptions) if len(ret) == 0 { panic("no return value specified for OrgList") } var r0 []*model.Org var r1 error if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) ([]*model.Org, error)); ok { return returnFunc(listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.ListOptions) []*model.Org); ok { r0 = returnFunc(listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Org) } } if returnFunc, ok := ret.Get(1).(func(*model.ListOptions) error); ok { r1 = returnFunc(listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgList' type MockStore_OrgList_Call struct { *mock.Call } // OrgList is a helper method to define mock.On call // - listOptions *model.ListOptions func (_e *MockStore_Expecter) OrgList(listOptions interface{}) *MockStore_OrgList_Call { return &MockStore_OrgList_Call{Call: _e.mock.On("OrgList", listOptions)} } func (_c *MockStore_OrgList_Call) Run(run func(listOptions *model.ListOptions)) *MockStore_OrgList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.ListOptions if args[0] != nil { arg0 = args[0].(*model.ListOptions) } run( arg0, ) }) return _c } func (_c *MockStore_OrgList_Call) Return(orgs []*model.Org, err error) *MockStore_OrgList_Call { _c.Call.Return(orgs, err) return _c } func (_c *MockStore_OrgList_Call) RunAndReturn(run func(listOptions *model.ListOptions) ([]*model.Org, error)) *MockStore_OrgList_Call { _c.Call.Return(run) return _c } // OrgRegistryFind provides a mock function for the type MockStore func (_mock *MockStore) OrgRegistryFind(n int64, s string) (*model.Registry, error) { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for OrgRegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Registry, error)); ok { return returnFunc(n, s) } if returnFunc, ok := ret.Get(0).(func(int64, string) *model.Registry); ok { r0 = returnFunc(n, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(int64, string) error); ok { r1 = returnFunc(n, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgRegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryFind' type MockStore_OrgRegistryFind_Call struct { *mock.Call } // OrgRegistryFind is a helper method to define mock.On call // - n int64 // - s string func (_e *MockStore_Expecter) OrgRegistryFind(n interface{}, s interface{}) *MockStore_OrgRegistryFind_Call { return &MockStore_OrgRegistryFind_Call{Call: _e.mock.On("OrgRegistryFind", n, s)} } func (_c *MockStore_OrgRegistryFind_Call) Run(run func(n int64, s string)) *MockStore_OrgRegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_OrgRegistryFind_Call) Return(registry *model.Registry, err error) *MockStore_OrgRegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockStore_OrgRegistryFind_Call) RunAndReturn(run func(n int64, s string) (*model.Registry, error)) *MockStore_OrgRegistryFind_Call { _c.Call.Return(run) return _c } // OrgRegistryList provides a mock function for the type MockStore func (_mock *MockStore) OrgRegistryList(n int64, listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(n, listOptions) if len(ret) == 0 { panic("no return value specified for OrgRegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(n, listOptions) } if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Registry); ok { r0 = returnFunc(n, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { r1 = returnFunc(n, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgRegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRegistryList' type MockStore_OrgRegistryList_Call struct { *mock.Call } // OrgRegistryList is a helper method to define mock.On call // - n int64 // - listOptions *model.ListOptions func (_e *MockStore_Expecter) OrgRegistryList(n interface{}, listOptions interface{}) *MockStore_OrgRegistryList_Call { return &MockStore_OrgRegistryList_Call{Call: _e.mock.On("OrgRegistryList", n, listOptions)} } func (_c *MockStore_OrgRegistryList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockStore_OrgRegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_OrgRegistryList_Call) Return(registrys []*model.Registry, err error) *MockStore_OrgRegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockStore_OrgRegistryList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockStore_OrgRegistryList_Call { _c.Call.Return(run) return _c } // OrgRepoList provides a mock function for the type MockStore func (_mock *MockStore) OrgRepoList(org *model.Org, listOptions *model.ListOptions) ([]*model.Repo, error) { ret := _mock.Called(org, listOptions) if len(ret) == 0 { panic("no return value specified for OrgRepoList") } var r0 []*model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) ([]*model.Repo, error)); ok { return returnFunc(org, listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) []*model.Repo); ok { r0 = returnFunc(org, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(*model.Org, *model.ListOptions) error); ok { r1 = returnFunc(org, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgRepoList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgRepoList' type MockStore_OrgRepoList_Call struct { *mock.Call } // OrgRepoList is a helper method to define mock.On call // - org *model.Org // - listOptions *model.ListOptions func (_e *MockStore_Expecter) OrgRepoList(org interface{}, listOptions interface{}) *MockStore_OrgRepoList_Call { return &MockStore_OrgRepoList_Call{Call: _e.mock.On("OrgRepoList", org, listOptions)} } func (_c *MockStore_OrgRepoList_Call) Run(run func(org *model.Org, listOptions *model.ListOptions)) *MockStore_OrgRepoList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Org if args[0] != nil { arg0 = args[0].(*model.Org) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_OrgRepoList_Call) Return(repos []*model.Repo, err error) *MockStore_OrgRepoList_Call { _c.Call.Return(repos, err) return _c } func (_c *MockStore_OrgRepoList_Call) RunAndReturn(run func(org *model.Org, listOptions *model.ListOptions) ([]*model.Repo, error)) *MockStore_OrgRepoList_Call { _c.Call.Return(run) return _c } // OrgSecretFind provides a mock function for the type MockStore func (_mock *MockStore) OrgSecretFind(n int64, s string) (*model.Secret, error) { ret := _mock.Called(n, s) if len(ret) == 0 { panic("no return value specified for OrgSecretFind") } var r0 *model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(int64, string) (*model.Secret, error)); ok { return returnFunc(n, s) } if returnFunc, ok := ret.Get(0).(func(int64, string) *model.Secret); ok { r0 = returnFunc(n, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(int64, string) error); ok { r1 = returnFunc(n, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgSecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretFind' type MockStore_OrgSecretFind_Call struct { *mock.Call } // OrgSecretFind is a helper method to define mock.On call // - n int64 // - s string func (_e *MockStore_Expecter) OrgSecretFind(n interface{}, s interface{}) *MockStore_OrgSecretFind_Call { return &MockStore_OrgSecretFind_Call{Call: _e.mock.On("OrgSecretFind", n, s)} } func (_c *MockStore_OrgSecretFind_Call) Run(run func(n int64, s string)) *MockStore_OrgSecretFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_OrgSecretFind_Call) Return(secret *model.Secret, err error) *MockStore_OrgSecretFind_Call { _c.Call.Return(secret, err) return _c } func (_c *MockStore_OrgSecretFind_Call) RunAndReturn(run func(n int64, s string) (*model.Secret, error)) *MockStore_OrgSecretFind_Call { _c.Call.Return(run) return _c } // OrgSecretList provides a mock function for the type MockStore func (_mock *MockStore) OrgSecretList(n int64, listOptions *model.ListOptions) ([]*model.Secret, error) { ret := _mock.Called(n, listOptions) if len(ret) == 0 { panic("no return value specified for OrgSecretList") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Secret, error)); ok { return returnFunc(n, listOptions) } if returnFunc, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Secret); ok { r0 = returnFunc(n, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok { r1 = returnFunc(n, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_OrgSecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgSecretList' type MockStore_OrgSecretList_Call struct { *mock.Call } // OrgSecretList is a helper method to define mock.On call // - n int64 // - listOptions *model.ListOptions func (_e *MockStore_Expecter) OrgSecretList(n interface{}, listOptions interface{}) *MockStore_OrgSecretList_Call { return &MockStore_OrgSecretList_Call{Call: _e.mock.On("OrgSecretList", n, listOptions)} } func (_c *MockStore_OrgSecretList_Call) Run(run func(n int64, listOptions *model.ListOptions)) *MockStore_OrgSecretList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_OrgSecretList_Call) Return(secrets []*model.Secret, err error) *MockStore_OrgSecretList_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockStore_OrgSecretList_Call) RunAndReturn(run func(n int64, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockStore_OrgSecretList_Call { _c.Call.Return(run) return _c } // OrgUpdate provides a mock function for the type MockStore func (_mock *MockStore) OrgUpdate(org *model.Org) error { ret := _mock.Called(org) if len(ret) == 0 { panic("no return value specified for OrgUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Org) error); ok { r0 = returnFunc(org) } else { r0 = ret.Error(0) } return r0 } // MockStore_OrgUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OrgUpdate' type MockStore_OrgUpdate_Call struct { *mock.Call } // OrgUpdate is a helper method to define mock.On call // - org *model.Org func (_e *MockStore_Expecter) OrgUpdate(org interface{}) *MockStore_OrgUpdate_Call { return &MockStore_OrgUpdate_Call{Call: _e.mock.On("OrgUpdate", org)} } func (_c *MockStore_OrgUpdate_Call) Run(run func(org *model.Org)) *MockStore_OrgUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Org if args[0] != nil { arg0 = args[0].(*model.Org) } run( arg0, ) }) return _c } func (_c *MockStore_OrgUpdate_Call) Return(err error) *MockStore_OrgUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_OrgUpdate_Call) RunAndReturn(run func(org *model.Org) error) *MockStore_OrgUpdate_Call { _c.Call.Return(run) return _c } // PermFind provides a mock function for the type MockStore func (_mock *MockStore) PermFind(user *model.User, repo *model.Repo) (*model.Perm, error) { ret := _mock.Called(user, repo) if len(ret) == 0 { panic("no return value specified for PermFind") } var r0 *model.Perm var r1 error if returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) (*model.Perm, error)); ok { return returnFunc(user, repo) } if returnFunc, ok := ret.Get(0).(func(*model.User, *model.Repo) *model.Perm); ok { r0 = returnFunc(user, repo) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Perm) } } if returnFunc, ok := ret.Get(1).(func(*model.User, *model.Repo) error); ok { r1 = returnFunc(user, repo) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_PermFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PermFind' type MockStore_PermFind_Call struct { *mock.Call } // PermFind is a helper method to define mock.On call // - user *model.User // - repo *model.Repo func (_e *MockStore_Expecter) PermFind(user interface{}, repo interface{}) *MockStore_PermFind_Call { return &MockStore_PermFind_Call{Call: _e.mock.On("PermFind", user, repo)} } func (_c *MockStore_PermFind_Call) Run(run func(user *model.User, repo *model.Repo)) *MockStore_PermFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } var arg1 *model.Repo if args[1] != nil { arg1 = args[1].(*model.Repo) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_PermFind_Call) Return(perm *model.Perm, err error) *MockStore_PermFind_Call { _c.Call.Return(perm, err) return _c } func (_c *MockStore_PermFind_Call) RunAndReturn(run func(user *model.User, repo *model.Repo) (*model.Perm, error)) *MockStore_PermFind_Call { _c.Call.Return(run) return _c } // PermPrune provides a mock function for the type MockStore func (_mock *MockStore) PermPrune(userID int64, keepRepoIDs []int64) error { ret := _mock.Called(userID, keepRepoIDs) if len(ret) == 0 { panic("no return value specified for PermPrune") } var r0 error if returnFunc, ok := ret.Get(0).(func(int64, []int64) error); ok { r0 = returnFunc(userID, keepRepoIDs) } else { r0 = ret.Error(0) } return r0 } // MockStore_PermPrune_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PermPrune' type MockStore_PermPrune_Call struct { *mock.Call } // PermPrune is a helper method to define mock.On call // - userID int64 // - keepRepoIDs []int64 func (_e *MockStore_Expecter) PermPrune(userID interface{}, keepRepoIDs interface{}) *MockStore_PermPrune_Call { return &MockStore_PermPrune_Call{Call: _e.mock.On("PermPrune", userID, keepRepoIDs)} } func (_c *MockStore_PermPrune_Call) Run(run func(userID int64, keepRepoIDs []int64)) *MockStore_PermPrune_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 []int64 if args[1] != nil { arg1 = args[1].([]int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_PermPrune_Call) Return(err error) *MockStore_PermPrune_Call { _c.Call.Return(err) return _c } func (_c *MockStore_PermPrune_Call) RunAndReturn(run func(userID int64, keepRepoIDs []int64) error) *MockStore_PermPrune_Call { _c.Call.Return(run) return _c } // PermUpsert provides a mock function for the type MockStore func (_mock *MockStore) PermUpsert(perm *model.Perm) error { ret := _mock.Called(perm) if len(ret) == 0 { panic("no return value specified for PermUpsert") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Perm) error); ok { r0 = returnFunc(perm) } else { r0 = ret.Error(0) } return r0 } // MockStore_PermUpsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PermUpsert' type MockStore_PermUpsert_Call struct { *mock.Call } // PermUpsert is a helper method to define mock.On call // - perm *model.Perm func (_e *MockStore_Expecter) PermUpsert(perm interface{}) *MockStore_PermUpsert_Call { return &MockStore_PermUpsert_Call{Call: _e.mock.On("PermUpsert", perm)} } func (_c *MockStore_PermUpsert_Call) Run(run func(perm *model.Perm)) *MockStore_PermUpsert_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Perm if args[0] != nil { arg0 = args[0].(*model.Perm) } run( arg0, ) }) return _c } func (_c *MockStore_PermUpsert_Call) Return(err error) *MockStore_PermUpsert_Call { _c.Call.Return(err) return _c } func (_c *MockStore_PermUpsert_Call) RunAndReturn(run func(perm *model.Perm) error) *MockStore_PermUpsert_Call { _c.Call.Return(run) return _c } // Ping provides a mock function for the type MockStore func (_mock *MockStore) Ping() error { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for Ping") } var r0 error if returnFunc, ok := ret.Get(0).(func() error); ok { r0 = returnFunc() } else { r0 = ret.Error(0) } return r0 } // MockStore_Ping_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Ping' type MockStore_Ping_Call struct { *mock.Call } // Ping is a helper method to define mock.On call func (_e *MockStore_Expecter) Ping() *MockStore_Ping_Call { return &MockStore_Ping_Call{Call: _e.mock.On("Ping")} } func (_c *MockStore_Ping_Call) Run(run func()) *MockStore_Ping_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_Ping_Call) Return(err error) *MockStore_Ping_Call { _c.Call.Return(err) return _c } func (_c *MockStore_Ping_Call) RunAndReturn(run func() error) *MockStore_Ping_Call { _c.Call.Return(run) return _c } // PipelineConfigCreate provides a mock function for the type MockStore func (_mock *MockStore) PipelineConfigCreate(pipelineConfig *model.PipelineConfig) error { ret := _mock.Called(pipelineConfig) if len(ret) == 0 { panic("no return value specified for PipelineConfigCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.PipelineConfig) error); ok { r0 = returnFunc(pipelineConfig) } else { r0 = ret.Error(0) } return r0 } // MockStore_PipelineConfigCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PipelineConfigCreate' type MockStore_PipelineConfigCreate_Call struct { *mock.Call } // PipelineConfigCreate is a helper method to define mock.On call // - pipelineConfig *model.PipelineConfig func (_e *MockStore_Expecter) PipelineConfigCreate(pipelineConfig interface{}) *MockStore_PipelineConfigCreate_Call { return &MockStore_PipelineConfigCreate_Call{Call: _e.mock.On("PipelineConfigCreate", pipelineConfig)} } func (_c *MockStore_PipelineConfigCreate_Call) Run(run func(pipelineConfig *model.PipelineConfig)) *MockStore_PipelineConfigCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.PipelineConfig if args[0] != nil { arg0 = args[0].(*model.PipelineConfig) } run( arg0, ) }) return _c } func (_c *MockStore_PipelineConfigCreate_Call) Return(err error) *MockStore_PipelineConfigCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_PipelineConfigCreate_Call) RunAndReturn(run func(pipelineConfig *model.PipelineConfig) error) *MockStore_PipelineConfigCreate_Call { _c.Call.Return(run) return _c } // RegistryCreate provides a mock function for the type MockStore func (_mock *MockStore) RegistryCreate(registry *model.Registry) error { ret := _mock.Called(registry) if len(ret) == 0 { panic("no return value specified for RegistryCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok { r0 = returnFunc(registry) } else { r0 = ret.Error(0) } return r0 } // MockStore_RegistryCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryCreate' type MockStore_RegistryCreate_Call struct { *mock.Call } // RegistryCreate is a helper method to define mock.On call // - registry *model.Registry func (_e *MockStore_Expecter) RegistryCreate(registry interface{}) *MockStore_RegistryCreate_Call { return &MockStore_RegistryCreate_Call{Call: _e.mock.On("RegistryCreate", registry)} } func (_c *MockStore_RegistryCreate_Call) Run(run func(registry *model.Registry)) *MockStore_RegistryCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Registry if args[0] != nil { arg0 = args[0].(*model.Registry) } run( arg0, ) }) return _c } func (_c *MockStore_RegistryCreate_Call) Return(err error) *MockStore_RegistryCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_RegistryCreate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockStore_RegistryCreate_Call { _c.Call.Return(run) return _c } // RegistryDelete provides a mock function for the type MockStore func (_mock *MockStore) RegistryDelete(registry *model.Registry) error { ret := _mock.Called(registry) if len(ret) == 0 { panic("no return value specified for RegistryDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok { r0 = returnFunc(registry) } else { r0 = ret.Error(0) } return r0 } // MockStore_RegistryDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryDelete' type MockStore_RegistryDelete_Call struct { *mock.Call } // RegistryDelete is a helper method to define mock.On call // - registry *model.Registry func (_e *MockStore_Expecter) RegistryDelete(registry interface{}) *MockStore_RegistryDelete_Call { return &MockStore_RegistryDelete_Call{Call: _e.mock.On("RegistryDelete", registry)} } func (_c *MockStore_RegistryDelete_Call) Run(run func(registry *model.Registry)) *MockStore_RegistryDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Registry if args[0] != nil { arg0 = args[0].(*model.Registry) } run( arg0, ) }) return _c } func (_c *MockStore_RegistryDelete_Call) Return(err error) *MockStore_RegistryDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_RegistryDelete_Call) RunAndReturn(run func(registry *model.Registry) error) *MockStore_RegistryDelete_Call { _c.Call.Return(run) return _c } // RegistryFind provides a mock function for the type MockStore func (_mock *MockStore) RegistryFind(repo *model.Repo, s string) (*model.Registry, error) { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for RegistryFind") } var r0 *model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Registry, error)); ok { return returnFunc(repo, s) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Registry); ok { r0 = returnFunc(repo, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok { r1 = returnFunc(repo, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_RegistryFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryFind' type MockStore_RegistryFind_Call struct { *mock.Call } // RegistryFind is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockStore_Expecter) RegistryFind(repo interface{}, s interface{}) *MockStore_RegistryFind_Call { return &MockStore_RegistryFind_Call{Call: _e.mock.On("RegistryFind", repo, s)} } func (_c *MockStore_RegistryFind_Call) Run(run func(repo *model.Repo, s string)) *MockStore_RegistryFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_RegistryFind_Call) Return(registry *model.Registry, err error) *MockStore_RegistryFind_Call { _c.Call.Return(registry, err) return _c } func (_c *MockStore_RegistryFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Registry, error)) *MockStore_RegistryFind_Call { _c.Call.Return(run) return _c } // RegistryList provides a mock function for the type MockStore func (_mock *MockStore) RegistryList(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Registry, error) { ret := _mock.Called(repo, b, listOptions) if len(ret) == 0 { panic("no return value specified for RegistryList") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) ([]*model.Registry, error)); ok { return returnFunc(repo, b, listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) []*model.Registry); ok { r0 = returnFunc(repo, b, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, bool, *model.ListOptions) error); ok { r1 = returnFunc(repo, b, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_RegistryList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryList' type MockStore_RegistryList_Call struct { *mock.Call } // RegistryList is a helper method to define mock.On call // - repo *model.Repo // - b bool // - listOptions *model.ListOptions func (_e *MockStore_Expecter) RegistryList(repo interface{}, b interface{}, listOptions interface{}) *MockStore_RegistryList_Call { return &MockStore_RegistryList_Call{Call: _e.mock.On("RegistryList", repo, b, listOptions)} } func (_c *MockStore_RegistryList_Call) Run(run func(repo *model.Repo, b bool, listOptions *model.ListOptions)) *MockStore_RegistryList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 bool if args[1] != nil { arg1 = args[1].(bool) } var arg2 *model.ListOptions if args[2] != nil { arg2 = args[2].(*model.ListOptions) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockStore_RegistryList_Call) Return(registrys []*model.Registry, err error) *MockStore_RegistryList_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockStore_RegistryList_Call) RunAndReturn(run func(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Registry, error)) *MockStore_RegistryList_Call { _c.Call.Return(run) return _c } // RegistryListAll provides a mock function for the type MockStore func (_mock *MockStore) RegistryListAll() ([]*model.Registry, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for RegistryListAll") } var r0 []*model.Registry var r1 error if returnFunc, ok := ret.Get(0).(func() ([]*model.Registry, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() []*model.Registry); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Registry) } } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_RegistryListAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryListAll' type MockStore_RegistryListAll_Call struct { *mock.Call } // RegistryListAll is a helper method to define mock.On call func (_e *MockStore_Expecter) RegistryListAll() *MockStore_RegistryListAll_Call { return &MockStore_RegistryListAll_Call{Call: _e.mock.On("RegistryListAll")} } func (_c *MockStore_RegistryListAll_Call) Run(run func()) *MockStore_RegistryListAll_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_RegistryListAll_Call) Return(registrys []*model.Registry, err error) *MockStore_RegistryListAll_Call { _c.Call.Return(registrys, err) return _c } func (_c *MockStore_RegistryListAll_Call) RunAndReturn(run func() ([]*model.Registry, error)) *MockStore_RegistryListAll_Call { _c.Call.Return(run) return _c } // RegistryUpdate provides a mock function for the type MockStore func (_mock *MockStore) RegistryUpdate(registry *model.Registry) error { ret := _mock.Called(registry) if len(ret) == 0 { panic("no return value specified for RegistryUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Registry) error); ok { r0 = returnFunc(registry) } else { r0 = ret.Error(0) } return r0 } // MockStore_RegistryUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryUpdate' type MockStore_RegistryUpdate_Call struct { *mock.Call } // RegistryUpdate is a helper method to define mock.On call // - registry *model.Registry func (_e *MockStore_Expecter) RegistryUpdate(registry interface{}) *MockStore_RegistryUpdate_Call { return &MockStore_RegistryUpdate_Call{Call: _e.mock.On("RegistryUpdate", registry)} } func (_c *MockStore_RegistryUpdate_Call) Run(run func(registry *model.Registry)) *MockStore_RegistryUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Registry if args[0] != nil { arg0 = args[0].(*model.Registry) } run( arg0, ) }) return _c } func (_c *MockStore_RegistryUpdate_Call) Return(err error) *MockStore_RegistryUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_RegistryUpdate_Call) RunAndReturn(run func(registry *model.Registry) error) *MockStore_RegistryUpdate_Call { _c.Call.Return(run) return _c } // RepoList provides a mock function for the type MockStore func (_mock *MockStore) RepoList(user *model.User, owned bool, active bool, filter *model.RepoFilter) ([]*model.Repo, error) { ret := _mock.Called(user, owned, active, filter) if len(ret) == 0 { panic("no return value specified for RepoList") } var r0 []*model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(*model.User, bool, bool, *model.RepoFilter) ([]*model.Repo, error)); ok { return returnFunc(user, owned, active, filter) } if returnFunc, ok := ret.Get(0).(func(*model.User, bool, bool, *model.RepoFilter) []*model.Repo); ok { r0 = returnFunc(user, owned, active, filter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(*model.User, bool, bool, *model.RepoFilter) error); ok { r1 = returnFunc(user, owned, active, filter) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_RepoList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoList' type MockStore_RepoList_Call struct { *mock.Call } // RepoList is a helper method to define mock.On call // - user *model.User // - owned bool // - active bool // - filter *model.RepoFilter func (_e *MockStore_Expecter) RepoList(user interface{}, owned interface{}, active interface{}, filter interface{}) *MockStore_RepoList_Call { return &MockStore_RepoList_Call{Call: _e.mock.On("RepoList", user, owned, active, filter)} } func (_c *MockStore_RepoList_Call) Run(run func(user *model.User, owned bool, active bool, filter *model.RepoFilter)) *MockStore_RepoList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } var arg1 bool if args[1] != nil { arg1 = args[1].(bool) } var arg2 bool if args[2] != nil { arg2 = args[2].(bool) } var arg3 *model.RepoFilter if args[3] != nil { arg3 = args[3].(*model.RepoFilter) } run( arg0, arg1, arg2, arg3, ) }) return _c } func (_c *MockStore_RepoList_Call) Return(repos []*model.Repo, err error) *MockStore_RepoList_Call { _c.Call.Return(repos, err) return _c } func (_c *MockStore_RepoList_Call) RunAndReturn(run func(user *model.User, owned bool, active bool, filter *model.RepoFilter) ([]*model.Repo, error)) *MockStore_RepoList_Call { _c.Call.Return(run) return _c } // RepoListAll provides a mock function for the type MockStore func (_mock *MockStore) RepoListAll(active bool, p *model.ListOptions) ([]*model.Repo, error) { ret := _mock.Called(active, p) if len(ret) == 0 { panic("no return value specified for RepoListAll") } var r0 []*model.Repo var r1 error if returnFunc, ok := ret.Get(0).(func(bool, *model.ListOptions) ([]*model.Repo, error)); ok { return returnFunc(active, p) } if returnFunc, ok := ret.Get(0).(func(bool, *model.ListOptions) []*model.Repo); ok { r0 = returnFunc(active, p) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Repo) } } if returnFunc, ok := ret.Get(1).(func(bool, *model.ListOptions) error); ok { r1 = returnFunc(active, p) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_RepoListAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoListAll' type MockStore_RepoListAll_Call struct { *mock.Call } // RepoListAll is a helper method to define mock.On call // - active bool // - p *model.ListOptions func (_e *MockStore_Expecter) RepoListAll(active interface{}, p interface{}) *MockStore_RepoListAll_Call { return &MockStore_RepoListAll_Call{Call: _e.mock.On("RepoListAll", active, p)} } func (_c *MockStore_RepoListAll_Call) Run(run func(active bool, p *model.ListOptions)) *MockStore_RepoListAll_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 bool if args[0] != nil { arg0 = args[0].(bool) } var arg1 *model.ListOptions if args[1] != nil { arg1 = args[1].(*model.ListOptions) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_RepoListAll_Call) Return(repos []*model.Repo, err error) *MockStore_RepoListAll_Call { _c.Call.Return(repos, err) return _c } func (_c *MockStore_RepoListAll_Call) RunAndReturn(run func(active bool, p *model.ListOptions) ([]*model.Repo, error)) *MockStore_RepoListAll_Call { _c.Call.Return(run) return _c } // RepoListLatest provides a mock function for the type MockStore func (_mock *MockStore) RepoListLatest(user *model.User) ([]*model.Feed, error) { ret := _mock.Called(user) if len(ret) == 0 { panic("no return value specified for RepoListLatest") } var r0 []*model.Feed var r1 error if returnFunc, ok := ret.Get(0).(func(*model.User) ([]*model.Feed, error)); ok { return returnFunc(user) } if returnFunc, ok := ret.Get(0).(func(*model.User) []*model.Feed); ok { r0 = returnFunc(user) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Feed) } } if returnFunc, ok := ret.Get(1).(func(*model.User) error); ok { r1 = returnFunc(user) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_RepoListLatest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepoListLatest' type MockStore_RepoListLatest_Call struct { *mock.Call } // RepoListLatest is a helper method to define mock.On call // - user *model.User func (_e *MockStore_Expecter) RepoListLatest(user interface{}) *MockStore_RepoListLatest_Call { return &MockStore_RepoListLatest_Call{Call: _e.mock.On("RepoListLatest", user)} } func (_c *MockStore_RepoListLatest_Call) Run(run func(user *model.User)) *MockStore_RepoListLatest_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } run( arg0, ) }) return _c } func (_c *MockStore_RepoListLatest_Call) Return(feeds []*model.Feed, err error) *MockStore_RepoListLatest_Call { _c.Call.Return(feeds, err) return _c } func (_c *MockStore_RepoListLatest_Call) RunAndReturn(run func(user *model.User) ([]*model.Feed, error)) *MockStore_RepoListLatest_Call { _c.Call.Return(run) return _c } // SecretCreate provides a mock function for the type MockStore func (_mock *MockStore) SecretCreate(secret *model.Secret) error { ret := _mock.Called(secret) if len(ret) == 0 { panic("no return value specified for SecretCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok { r0 = returnFunc(secret) } else { r0 = ret.Error(0) } return r0 } // MockStore_SecretCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretCreate' type MockStore_SecretCreate_Call struct { *mock.Call } // SecretCreate is a helper method to define mock.On call // - secret *model.Secret func (_e *MockStore_Expecter) SecretCreate(secret interface{}) *MockStore_SecretCreate_Call { return &MockStore_SecretCreate_Call{Call: _e.mock.On("SecretCreate", secret)} } func (_c *MockStore_SecretCreate_Call) Run(run func(secret *model.Secret)) *MockStore_SecretCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Secret if args[0] != nil { arg0 = args[0].(*model.Secret) } run( arg0, ) }) return _c } func (_c *MockStore_SecretCreate_Call) Return(err error) *MockStore_SecretCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_SecretCreate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockStore_SecretCreate_Call { _c.Call.Return(run) return _c } // SecretDelete provides a mock function for the type MockStore func (_mock *MockStore) SecretDelete(secret *model.Secret) error { ret := _mock.Called(secret) if len(ret) == 0 { panic("no return value specified for SecretDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok { r0 = returnFunc(secret) } else { r0 = ret.Error(0) } return r0 } // MockStore_SecretDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretDelete' type MockStore_SecretDelete_Call struct { *mock.Call } // SecretDelete is a helper method to define mock.On call // - secret *model.Secret func (_e *MockStore_Expecter) SecretDelete(secret interface{}) *MockStore_SecretDelete_Call { return &MockStore_SecretDelete_Call{Call: _e.mock.On("SecretDelete", secret)} } func (_c *MockStore_SecretDelete_Call) Run(run func(secret *model.Secret)) *MockStore_SecretDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Secret if args[0] != nil { arg0 = args[0].(*model.Secret) } run( arg0, ) }) return _c } func (_c *MockStore_SecretDelete_Call) Return(err error) *MockStore_SecretDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_SecretDelete_Call) RunAndReturn(run func(secret *model.Secret) error) *MockStore_SecretDelete_Call { _c.Call.Return(run) return _c } // SecretFind provides a mock function for the type MockStore func (_mock *MockStore) SecretFind(repo *model.Repo, s string) (*model.Secret, error) { ret := _mock.Called(repo, s) if len(ret) == 0 { panic("no return value specified for SecretFind") } var r0 *model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) (*model.Secret, error)); ok { return returnFunc(repo, s) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, string) *model.Secret); ok { r0 = returnFunc(repo, s) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, string) error); ok { r1 = returnFunc(repo, s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_SecretFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretFind' type MockStore_SecretFind_Call struct { *mock.Call } // SecretFind is a helper method to define mock.On call // - repo *model.Repo // - s string func (_e *MockStore_Expecter) SecretFind(repo interface{}, s interface{}) *MockStore_SecretFind_Call { return &MockStore_SecretFind_Call{Call: _e.mock.On("SecretFind", repo, s)} } func (_c *MockStore_SecretFind_Call) Run(run func(repo *model.Repo, s string)) *MockStore_SecretFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_SecretFind_Call) Return(secret *model.Secret, err error) *MockStore_SecretFind_Call { _c.Call.Return(secret, err) return _c } func (_c *MockStore_SecretFind_Call) RunAndReturn(run func(repo *model.Repo, s string) (*model.Secret, error)) *MockStore_SecretFind_Call { _c.Call.Return(run) return _c } // SecretList provides a mock function for the type MockStore func (_mock *MockStore) SecretList(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Secret, error) { ret := _mock.Called(repo, b, listOptions) if len(ret) == 0 { panic("no return value specified for SecretList") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) ([]*model.Secret, error)); ok { return returnFunc(repo, b, listOptions) } if returnFunc, ok := ret.Get(0).(func(*model.Repo, bool, *model.ListOptions) []*model.Secret); ok { r0 = returnFunc(repo, b, listOptions) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func(*model.Repo, bool, *model.ListOptions) error); ok { r1 = returnFunc(repo, b, listOptions) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_SecretList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretList' type MockStore_SecretList_Call struct { *mock.Call } // SecretList is a helper method to define mock.On call // - repo *model.Repo // - b bool // - listOptions *model.ListOptions func (_e *MockStore_Expecter) SecretList(repo interface{}, b interface{}, listOptions interface{}) *MockStore_SecretList_Call { return &MockStore_SecretList_Call{Call: _e.mock.On("SecretList", repo, b, listOptions)} } func (_c *MockStore_SecretList_Call) Run(run func(repo *model.Repo, b bool, listOptions *model.ListOptions)) *MockStore_SecretList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } var arg1 bool if args[1] != nil { arg1 = args[1].(bool) } var arg2 *model.ListOptions if args[2] != nil { arg2 = args[2].(*model.ListOptions) } run( arg0, arg1, arg2, ) }) return _c } func (_c *MockStore_SecretList_Call) Return(secrets []*model.Secret, err error) *MockStore_SecretList_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockStore_SecretList_Call) RunAndReturn(run func(repo *model.Repo, b bool, listOptions *model.ListOptions) ([]*model.Secret, error)) *MockStore_SecretList_Call { _c.Call.Return(run) return _c } // SecretListAll provides a mock function for the type MockStore func (_mock *MockStore) SecretListAll() ([]*model.Secret, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for SecretListAll") } var r0 []*model.Secret var r1 error if returnFunc, ok := ret.Get(0).(func() ([]*model.Secret, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() []*model.Secret); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Secret) } } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_SecretListAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretListAll' type MockStore_SecretListAll_Call struct { *mock.Call } // SecretListAll is a helper method to define mock.On call func (_e *MockStore_Expecter) SecretListAll() *MockStore_SecretListAll_Call { return &MockStore_SecretListAll_Call{Call: _e.mock.On("SecretListAll")} } func (_c *MockStore_SecretListAll_Call) Run(run func()) *MockStore_SecretListAll_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_SecretListAll_Call) Return(secrets []*model.Secret, err error) *MockStore_SecretListAll_Call { _c.Call.Return(secrets, err) return _c } func (_c *MockStore_SecretListAll_Call) RunAndReturn(run func() ([]*model.Secret, error)) *MockStore_SecretListAll_Call { _c.Call.Return(run) return _c } // SecretUpdate provides a mock function for the type MockStore func (_mock *MockStore) SecretUpdate(secret *model.Secret) error { ret := _mock.Called(secret) if len(ret) == 0 { panic("no return value specified for SecretUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Secret) error); ok { r0 = returnFunc(secret) } else { r0 = ret.Error(0) } return r0 } // MockStore_SecretUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SecretUpdate' type MockStore_SecretUpdate_Call struct { *mock.Call } // SecretUpdate is a helper method to define mock.On call // - secret *model.Secret func (_e *MockStore_Expecter) SecretUpdate(secret interface{}) *MockStore_SecretUpdate_Call { return &MockStore_SecretUpdate_Call{Call: _e.mock.On("SecretUpdate", secret)} } func (_c *MockStore_SecretUpdate_Call) Run(run func(secret *model.Secret)) *MockStore_SecretUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Secret if args[0] != nil { arg0 = args[0].(*model.Secret) } run( arg0, ) }) return _c } func (_c *MockStore_SecretUpdate_Call) Return(err error) *MockStore_SecretUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_SecretUpdate_Call) RunAndReturn(run func(secret *model.Secret) error) *MockStore_SecretUpdate_Call { _c.Call.Return(run) return _c } // ServerConfigDelete provides a mock function for the type MockStore func (_mock *MockStore) ServerConfigDelete(s string) error { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for ServerConfigDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(string) error); ok { r0 = returnFunc(s) } else { r0 = ret.Error(0) } return r0 } // MockStore_ServerConfigDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerConfigDelete' type MockStore_ServerConfigDelete_Call struct { *mock.Call } // ServerConfigDelete is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) ServerConfigDelete(s interface{}) *MockStore_ServerConfigDelete_Call { return &MockStore_ServerConfigDelete_Call{Call: _e.mock.On("ServerConfigDelete", s)} } func (_c *MockStore_ServerConfigDelete_Call) Run(run func(s string)) *MockStore_ServerConfigDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_ServerConfigDelete_Call) Return(err error) *MockStore_ServerConfigDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_ServerConfigDelete_Call) RunAndReturn(run func(s string) error) *MockStore_ServerConfigDelete_Call { _c.Call.Return(run) return _c } // ServerConfigGet provides a mock function for the type MockStore func (_mock *MockStore) ServerConfigGet(s string) (string, error) { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for ServerConfigGet") } var r0 string var r1 error if returnFunc, ok := ret.Get(0).(func(string) (string, error)); ok { return returnFunc(s) } if returnFunc, ok := ret.Get(0).(func(string) string); ok { r0 = returnFunc(s) } else { r0 = ret.Get(0).(string) } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(s) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_ServerConfigGet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerConfigGet' type MockStore_ServerConfigGet_Call struct { *mock.Call } // ServerConfigGet is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) ServerConfigGet(s interface{}) *MockStore_ServerConfigGet_Call { return &MockStore_ServerConfigGet_Call{Call: _e.mock.On("ServerConfigGet", s)} } func (_c *MockStore_ServerConfigGet_Call) Run(run func(s string)) *MockStore_ServerConfigGet_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_ServerConfigGet_Call) Return(s1 string, err error) *MockStore_ServerConfigGet_Call { _c.Call.Return(s1, err) return _c } func (_c *MockStore_ServerConfigGet_Call) RunAndReturn(run func(s string) (string, error)) *MockStore_ServerConfigGet_Call { _c.Call.Return(run) return _c } // ServerConfigSet provides a mock function for the type MockStore func (_mock *MockStore) ServerConfigSet(s string, s1 string) error { ret := _mock.Called(s, s1) if len(ret) == 0 { panic("no return value specified for ServerConfigSet") } var r0 error if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { r0 = returnFunc(s, s1) } else { r0 = ret.Error(0) } return r0 } // MockStore_ServerConfigSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServerConfigSet' type MockStore_ServerConfigSet_Call struct { *mock.Call } // ServerConfigSet is a helper method to define mock.On call // - s string // - s1 string func (_e *MockStore_Expecter) ServerConfigSet(s interface{}, s1 interface{}) *MockStore_ServerConfigSet_Call { return &MockStore_ServerConfigSet_Call{Call: _e.mock.On("ServerConfigSet", s, s1)} } func (_c *MockStore_ServerConfigSet_Call) Run(run func(s string, s1 string)) *MockStore_ServerConfigSet_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } var arg1 string if args[1] != nil { arg1 = args[1].(string) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_ServerConfigSet_Call) Return(err error) *MockStore_ServerConfigSet_Call { _c.Call.Return(err) return _c } func (_c *MockStore_ServerConfigSet_Call) RunAndReturn(run func(s string, s1 string) error) *MockStore_ServerConfigSet_Call { _c.Call.Return(run) return _c } // StepByUUID provides a mock function for the type MockStore func (_mock *MockStore) StepByUUID(uuid string) (*model.Step, error) { ret := _mock.Called(uuid) if len(ret) == 0 { panic("no return value specified for StepByUUID") } var r0 *model.Step var r1 error if returnFunc, ok := ret.Get(0).(func(string) (*model.Step, error)); ok { return returnFunc(uuid) } if returnFunc, ok := ret.Get(0).(func(string) *model.Step); ok { r0 = returnFunc(uuid) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Step) } } if returnFunc, ok := ret.Get(1).(func(string) error); ok { r1 = returnFunc(uuid) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_StepByUUID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepByUUID' type MockStore_StepByUUID_Call struct { *mock.Call } // StepByUUID is a helper method to define mock.On call // - uuid string func (_e *MockStore_Expecter) StepByUUID(uuid interface{}) *MockStore_StepByUUID_Call { return &MockStore_StepByUUID_Call{Call: _e.mock.On("StepByUUID", uuid)} } func (_c *MockStore_StepByUUID_Call) Run(run func(uuid string)) *MockStore_StepByUUID_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_StepByUUID_Call) Return(step *model.Step, err error) *MockStore_StepByUUID_Call { _c.Call.Return(step, err) return _c } func (_c *MockStore_StepByUUID_Call) RunAndReturn(run func(uuid string) (*model.Step, error)) *MockStore_StepByUUID_Call { _c.Call.Return(run) return _c } // StepFinished provides a mock function for the type MockStore func (_mock *MockStore) StepFinished(step *model.Step) { _mock.Called(step) return } // MockStore_StepFinished_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepFinished' type MockStore_StepFinished_Call struct { *mock.Call } // StepFinished is a helper method to define mock.On call // - step *model.Step func (_e *MockStore_Expecter) StepFinished(step interface{}) *MockStore_StepFinished_Call { return &MockStore_StepFinished_Call{Call: _e.mock.On("StepFinished", step)} } func (_c *MockStore_StepFinished_Call) Run(run func(step *model.Step)) *MockStore_StepFinished_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockStore_StepFinished_Call) Return() *MockStore_StepFinished_Call { _c.Call.Return() return _c } func (_c *MockStore_StepFinished_Call) RunAndReturn(run func(step *model.Step)) *MockStore_StepFinished_Call { _c.Run(run) return _c } // StepList provides a mock function for the type MockStore func (_mock *MockStore) StepList(pipelineID int64) ([]*model.Step, error) { ret := _mock.Called(pipelineID) if len(ret) == 0 { panic("no return value specified for StepList") } var r0 []*model.Step var r1 error if returnFunc, ok := ret.Get(0).(func(int64) ([]*model.Step, error)); ok { return returnFunc(pipelineID) } if returnFunc, ok := ret.Get(0).(func(int64) []*model.Step); ok { r0 = returnFunc(pipelineID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Step) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(pipelineID) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_StepList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepList' type MockStore_StepList_Call struct { *mock.Call } // StepList is a helper method to define mock.On call // - pipelineID int64 func (_e *MockStore_Expecter) StepList(pipelineID interface{}) *MockStore_StepList_Call { return &MockStore_StepList_Call{Call: _e.mock.On("StepList", pipelineID)} } func (_c *MockStore_StepList_Call) Run(run func(pipelineID int64)) *MockStore_StepList_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_StepList_Call) Return(steps []*model.Step, err error) *MockStore_StepList_Call { _c.Call.Return(steps, err) return _c } func (_c *MockStore_StepList_Call) RunAndReturn(run func(pipelineID int64) ([]*model.Step, error)) *MockStore_StepList_Call { _c.Call.Return(run) return _c } // StepListFromWorkflowFind provides a mock function for the type MockStore func (_mock *MockStore) StepListFromWorkflowFind(workflow *model.Workflow) ([]*model.Step, error) { ret := _mock.Called(workflow) if len(ret) == 0 { panic("no return value specified for StepListFromWorkflowFind") } var r0 []*model.Step var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Workflow) ([]*model.Step, error)); ok { return returnFunc(workflow) } if returnFunc, ok := ret.Get(0).(func(*model.Workflow) []*model.Step); ok { r0 = returnFunc(workflow) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Step) } } if returnFunc, ok := ret.Get(1).(func(*model.Workflow) error); ok { r1 = returnFunc(workflow) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_StepListFromWorkflowFind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepListFromWorkflowFind' type MockStore_StepListFromWorkflowFind_Call struct { *mock.Call } // StepListFromWorkflowFind is a helper method to define mock.On call // - workflow *model.Workflow func (_e *MockStore_Expecter) StepListFromWorkflowFind(workflow interface{}) *MockStore_StepListFromWorkflowFind_Call { return &MockStore_StepListFromWorkflowFind_Call{Call: _e.mock.On("StepListFromWorkflowFind", workflow)} } func (_c *MockStore_StepListFromWorkflowFind_Call) Run(run func(workflow *model.Workflow)) *MockStore_StepListFromWorkflowFind_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Workflow if args[0] != nil { arg0 = args[0].(*model.Workflow) } run( arg0, ) }) return _c } func (_c *MockStore_StepListFromWorkflowFind_Call) Return(steps []*model.Step, err error) *MockStore_StepListFromWorkflowFind_Call { _c.Call.Return(steps, err) return _c } func (_c *MockStore_StepListFromWorkflowFind_Call) RunAndReturn(run func(workflow *model.Workflow) ([]*model.Step, error)) *MockStore_StepListFromWorkflowFind_Call { _c.Call.Return(run) return _c } // StepLoad provides a mock function for the type MockStore func (_mock *MockStore) StepLoad(pipelineID int64, stepID int64) (*model.Step, error) { ret := _mock.Called(pipelineID, stepID) if len(ret) == 0 { panic("no return value specified for StepLoad") } var r0 *model.Step var r1 error if returnFunc, ok := ret.Get(0).(func(int64, int64) (*model.Step, error)); ok { return returnFunc(pipelineID, stepID) } if returnFunc, ok := ret.Get(0).(func(int64, int64) *model.Step); ok { r0 = returnFunc(pipelineID, stepID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Step) } } if returnFunc, ok := ret.Get(1).(func(int64, int64) error); ok { r1 = returnFunc(pipelineID, stepID) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_StepLoad_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepLoad' type MockStore_StepLoad_Call struct { *mock.Call } // StepLoad is a helper method to define mock.On call // - pipelineID int64 // - stepID int64 func (_e *MockStore_Expecter) StepLoad(pipelineID interface{}, stepID interface{}) *MockStore_StepLoad_Call { return &MockStore_StepLoad_Call{Call: _e.mock.On("StepLoad", pipelineID, stepID)} } func (_c *MockStore_StepLoad_Call) Run(run func(pipelineID int64, stepID int64)) *MockStore_StepLoad_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } var arg1 int64 if args[1] != nil { arg1 = args[1].(int64) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_StepLoad_Call) Return(step *model.Step, err error) *MockStore_StepLoad_Call { _c.Call.Return(step, err) return _c } func (_c *MockStore_StepLoad_Call) RunAndReturn(run func(pipelineID int64, stepID int64) (*model.Step, error)) *MockStore_StepLoad_Call { _c.Call.Return(run) return _c } // StepUpdate provides a mock function for the type MockStore func (_mock *MockStore) StepUpdate(step *model.Step) error { ret := _mock.Called(step) if len(ret) == 0 { panic("no return value specified for StepUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Step) error); ok { r0 = returnFunc(step) } else { r0 = ret.Error(0) } return r0 } // MockStore_StepUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StepUpdate' type MockStore_StepUpdate_Call struct { *mock.Call } // StepUpdate is a helper method to define mock.On call // - step *model.Step func (_e *MockStore_Expecter) StepUpdate(step interface{}) *MockStore_StepUpdate_Call { return &MockStore_StepUpdate_Call{Call: _e.mock.On("StepUpdate", step)} } func (_c *MockStore_StepUpdate_Call) Run(run func(step *model.Step)) *MockStore_StepUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Step if args[0] != nil { arg0 = args[0].(*model.Step) } run( arg0, ) }) return _c } func (_c *MockStore_StepUpdate_Call) Return(err error) *MockStore_StepUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_StepUpdate_Call) RunAndReturn(run func(step *model.Step) error) *MockStore_StepUpdate_Call { _c.Call.Return(run) return _c } // TaskDelete provides a mock function for the type MockStore func (_mock *MockStore) TaskDelete(s string) error { ret := _mock.Called(s) if len(ret) == 0 { panic("no return value specified for TaskDelete") } var r0 error if returnFunc, ok := ret.Get(0).(func(string) error); ok { r0 = returnFunc(s) } else { r0 = ret.Error(0) } return r0 } // MockStore_TaskDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TaskDelete' type MockStore_TaskDelete_Call struct { *mock.Call } // TaskDelete is a helper method to define mock.On call // - s string func (_e *MockStore_Expecter) TaskDelete(s interface{}) *MockStore_TaskDelete_Call { return &MockStore_TaskDelete_Call{Call: _e.mock.On("TaskDelete", s)} } func (_c *MockStore_TaskDelete_Call) Run(run func(s string)) *MockStore_TaskDelete_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 string if args[0] != nil { arg0 = args[0].(string) } run( arg0, ) }) return _c } func (_c *MockStore_TaskDelete_Call) Return(err error) *MockStore_TaskDelete_Call { _c.Call.Return(err) return _c } func (_c *MockStore_TaskDelete_Call) RunAndReturn(run func(s string) error) *MockStore_TaskDelete_Call { _c.Call.Return(run) return _c } // TaskInsert provides a mock function for the type MockStore func (_mock *MockStore) TaskInsert(task *model.Task) error { ret := _mock.Called(task) if len(ret) == 0 { panic("no return value specified for TaskInsert") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Task) error); ok { r0 = returnFunc(task) } else { r0 = ret.Error(0) } return r0 } // MockStore_TaskInsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TaskInsert' type MockStore_TaskInsert_Call struct { *mock.Call } // TaskInsert is a helper method to define mock.On call // - task *model.Task func (_e *MockStore_Expecter) TaskInsert(task interface{}) *MockStore_TaskInsert_Call { return &MockStore_TaskInsert_Call{Call: _e.mock.On("TaskInsert", task)} } func (_c *MockStore_TaskInsert_Call) Run(run func(task *model.Task)) *MockStore_TaskInsert_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Task if args[0] != nil { arg0 = args[0].(*model.Task) } run( arg0, ) }) return _c } func (_c *MockStore_TaskInsert_Call) Return(err error) *MockStore_TaskInsert_Call { _c.Call.Return(err) return _c } func (_c *MockStore_TaskInsert_Call) RunAndReturn(run func(task *model.Task) error) *MockStore_TaskInsert_Call { _c.Call.Return(run) return _c } // TaskList provides a mock function for the type MockStore func (_mock *MockStore) TaskList() ([]*model.Task, error) { ret := _mock.Called() if len(ret) == 0 { panic("no return value specified for TaskList") } var r0 []*model.Task var r1 error if returnFunc, ok := ret.Get(0).(func() ([]*model.Task, error)); ok { return returnFunc() } if returnFunc, ok := ret.Get(0).(func() []*model.Task); ok { r0 = returnFunc() } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Task) } } if returnFunc, ok := ret.Get(1).(func() error); ok { r1 = returnFunc() } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_TaskList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TaskList' type MockStore_TaskList_Call struct { *mock.Call } // TaskList is a helper method to define mock.On call func (_e *MockStore_Expecter) TaskList() *MockStore_TaskList_Call { return &MockStore_TaskList_Call{Call: _e.mock.On("TaskList")} } func (_c *MockStore_TaskList_Call) Run(run func()) *MockStore_TaskList_Call { _c.Call.Run(func(args mock.Arguments) { run() }) return _c } func (_c *MockStore_TaskList_Call) Return(tasks []*model.Task, err error) *MockStore_TaskList_Call { _c.Call.Return(tasks, err) return _c } func (_c *MockStore_TaskList_Call) RunAndReturn(run func() ([]*model.Task, error)) *MockStore_TaskList_Call { _c.Call.Return(run) return _c } // UpdatePipeline provides a mock function for the type MockStore func (_mock *MockStore) UpdatePipeline(pipeline *model.Pipeline) error { ret := _mock.Called(pipeline) if len(ret) == 0 { panic("no return value specified for UpdatePipeline") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Pipeline) error); ok { r0 = returnFunc(pipeline) } else { r0 = ret.Error(0) } return r0 } // MockStore_UpdatePipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdatePipeline' type MockStore_UpdatePipeline_Call struct { *mock.Call } // UpdatePipeline is a helper method to define mock.On call // - pipeline *model.Pipeline func (_e *MockStore_Expecter) UpdatePipeline(pipeline interface{}) *MockStore_UpdatePipeline_Call { return &MockStore_UpdatePipeline_Call{Call: _e.mock.On("UpdatePipeline", pipeline)} } func (_c *MockStore_UpdatePipeline_Call) Run(run func(pipeline *model.Pipeline)) *MockStore_UpdatePipeline_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Pipeline if args[0] != nil { arg0 = args[0].(*model.Pipeline) } run( arg0, ) }) return _c } func (_c *MockStore_UpdatePipeline_Call) Return(err error) *MockStore_UpdatePipeline_Call { _c.Call.Return(err) return _c } func (_c *MockStore_UpdatePipeline_Call) RunAndReturn(run func(pipeline *model.Pipeline) error) *MockStore_UpdatePipeline_Call { _c.Call.Return(run) return _c } // UpdateRepo provides a mock function for the type MockStore func (_mock *MockStore) UpdateRepo(repo *model.Repo) error { ret := _mock.Called(repo) if len(ret) == 0 { panic("no return value specified for UpdateRepo") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Repo) error); ok { r0 = returnFunc(repo) } else { r0 = ret.Error(0) } return r0 } // MockStore_UpdateRepo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateRepo' type MockStore_UpdateRepo_Call struct { *mock.Call } // UpdateRepo is a helper method to define mock.On call // - repo *model.Repo func (_e *MockStore_Expecter) UpdateRepo(repo interface{}) *MockStore_UpdateRepo_Call { return &MockStore_UpdateRepo_Call{Call: _e.mock.On("UpdateRepo", repo)} } func (_c *MockStore_UpdateRepo_Call) Run(run func(repo *model.Repo)) *MockStore_UpdateRepo_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Repo if args[0] != nil { arg0 = args[0].(*model.Repo) } run( arg0, ) }) return _c } func (_c *MockStore_UpdateRepo_Call) Return(err error) *MockStore_UpdateRepo_Call { _c.Call.Return(err) return _c } func (_c *MockStore_UpdateRepo_Call) RunAndReturn(run func(repo *model.Repo) error) *MockStore_UpdateRepo_Call { _c.Call.Return(run) return _c } // UpdateUser provides a mock function for the type MockStore func (_mock *MockStore) UpdateUser(user *model.User) error { ret := _mock.Called(user) if len(ret) == 0 { panic("no return value specified for UpdateUser") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.User) error); ok { r0 = returnFunc(user) } else { r0 = ret.Error(0) } return r0 } // MockStore_UpdateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateUser' type MockStore_UpdateUser_Call struct { *mock.Call } // UpdateUser is a helper method to define mock.On call // - user *model.User func (_e *MockStore_Expecter) UpdateUser(user interface{}) *MockStore_UpdateUser_Call { return &MockStore_UpdateUser_Call{Call: _e.mock.On("UpdateUser", user)} } func (_c *MockStore_UpdateUser_Call) Run(run func(user *model.User)) *MockStore_UpdateUser_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } run( arg0, ) }) return _c } func (_c *MockStore_UpdateUser_Call) Return(err error) *MockStore_UpdateUser_Call { _c.Call.Return(err) return _c } func (_c *MockStore_UpdateUser_Call) RunAndReturn(run func(user *model.User) error) *MockStore_UpdateUser_Call { _c.Call.Return(run) return _c } // UserFeed provides a mock function for the type MockStore func (_mock *MockStore) UserFeed(user *model.User) ([]*model.Feed, error) { ret := _mock.Called(user) if len(ret) == 0 { panic("no return value specified for UserFeed") } var r0 []*model.Feed var r1 error if returnFunc, ok := ret.Get(0).(func(*model.User) ([]*model.Feed, error)); ok { return returnFunc(user) } if returnFunc, ok := ret.Get(0).(func(*model.User) []*model.Feed); ok { r0 = returnFunc(user) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Feed) } } if returnFunc, ok := ret.Get(1).(func(*model.User) error); ok { r1 = returnFunc(user) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_UserFeed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UserFeed' type MockStore_UserFeed_Call struct { *mock.Call } // UserFeed is a helper method to define mock.On call // - user *model.User func (_e *MockStore_Expecter) UserFeed(user interface{}) *MockStore_UserFeed_Call { return &MockStore_UserFeed_Call{Call: _e.mock.On("UserFeed", user)} } func (_c *MockStore_UserFeed_Call) Run(run func(user *model.User)) *MockStore_UserFeed_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.User if args[0] != nil { arg0 = args[0].(*model.User) } run( arg0, ) }) return _c } func (_c *MockStore_UserFeed_Call) Return(feeds []*model.Feed, err error) *MockStore_UserFeed_Call { _c.Call.Return(feeds, err) return _c } func (_c *MockStore_UserFeed_Call) RunAndReturn(run func(user *model.User) ([]*model.Feed, error)) *MockStore_UserFeed_Call { _c.Call.Return(run) return _c } // WorkflowGetTree provides a mock function for the type MockStore func (_mock *MockStore) WorkflowGetTree(pipeline *model.Pipeline) ([]*model.Workflow, error) { ret := _mock.Called(pipeline) if len(ret) == 0 { panic("no return value specified for WorkflowGetTree") } var r0 []*model.Workflow var r1 error if returnFunc, ok := ret.Get(0).(func(*model.Pipeline) ([]*model.Workflow, error)); ok { return returnFunc(pipeline) } if returnFunc, ok := ret.Get(0).(func(*model.Pipeline) []*model.Workflow); ok { r0 = returnFunc(pipeline) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.Workflow) } } if returnFunc, ok := ret.Get(1).(func(*model.Pipeline) error); ok { r1 = returnFunc(pipeline) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_WorkflowGetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowGetTree' type MockStore_WorkflowGetTree_Call struct { *mock.Call } // WorkflowGetTree is a helper method to define mock.On call // - pipeline *model.Pipeline func (_e *MockStore_Expecter) WorkflowGetTree(pipeline interface{}) *MockStore_WorkflowGetTree_Call { return &MockStore_WorkflowGetTree_Call{Call: _e.mock.On("WorkflowGetTree", pipeline)} } func (_c *MockStore_WorkflowGetTree_Call) Run(run func(pipeline *model.Pipeline)) *MockStore_WorkflowGetTree_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Pipeline if args[0] != nil { arg0 = args[0].(*model.Pipeline) } run( arg0, ) }) return _c } func (_c *MockStore_WorkflowGetTree_Call) Return(workflows []*model.Workflow, err error) *MockStore_WorkflowGetTree_Call { _c.Call.Return(workflows, err) return _c } func (_c *MockStore_WorkflowGetTree_Call) RunAndReturn(run func(pipeline *model.Pipeline) ([]*model.Workflow, error)) *MockStore_WorkflowGetTree_Call { _c.Call.Return(run) return _c } // WorkflowLoad provides a mock function for the type MockStore func (_mock *MockStore) WorkflowLoad(n int64) (*model.Workflow, error) { ret := _mock.Called(n) if len(ret) == 0 { panic("no return value specified for WorkflowLoad") } var r0 *model.Workflow var r1 error if returnFunc, ok := ret.Get(0).(func(int64) (*model.Workflow, error)); ok { return returnFunc(n) } if returnFunc, ok := ret.Get(0).(func(int64) *model.Workflow); ok { r0 = returnFunc(n) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.Workflow) } } if returnFunc, ok := ret.Get(1).(func(int64) error); ok { r1 = returnFunc(n) } else { r1 = ret.Error(1) } return r0, r1 } // MockStore_WorkflowLoad_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowLoad' type MockStore_WorkflowLoad_Call struct { *mock.Call } // WorkflowLoad is a helper method to define mock.On call // - n int64 func (_e *MockStore_Expecter) WorkflowLoad(n interface{}) *MockStore_WorkflowLoad_Call { return &MockStore_WorkflowLoad_Call{Call: _e.mock.On("WorkflowLoad", n)} } func (_c *MockStore_WorkflowLoad_Call) Run(run func(n int64)) *MockStore_WorkflowLoad_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 int64 if args[0] != nil { arg0 = args[0].(int64) } run( arg0, ) }) return _c } func (_c *MockStore_WorkflowLoad_Call) Return(workflow *model.Workflow, err error) *MockStore_WorkflowLoad_Call { _c.Call.Return(workflow, err) return _c } func (_c *MockStore_WorkflowLoad_Call) RunAndReturn(run func(n int64) (*model.Workflow, error)) *MockStore_WorkflowLoad_Call { _c.Call.Return(run) return _c } // WorkflowUpdate provides a mock function for the type MockStore func (_mock *MockStore) WorkflowUpdate(workflow *model.Workflow) error { ret := _mock.Called(workflow) if len(ret) == 0 { panic("no return value specified for WorkflowUpdate") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Workflow) error); ok { r0 = returnFunc(workflow) } else { r0 = ret.Error(0) } return r0 } // MockStore_WorkflowUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowUpdate' type MockStore_WorkflowUpdate_Call struct { *mock.Call } // WorkflowUpdate is a helper method to define mock.On call // - workflow *model.Workflow func (_e *MockStore_Expecter) WorkflowUpdate(workflow interface{}) *MockStore_WorkflowUpdate_Call { return &MockStore_WorkflowUpdate_Call{Call: _e.mock.On("WorkflowUpdate", workflow)} } func (_c *MockStore_WorkflowUpdate_Call) Run(run func(workflow *model.Workflow)) *MockStore_WorkflowUpdate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Workflow if args[0] != nil { arg0 = args[0].(*model.Workflow) } run( arg0, ) }) return _c } func (_c *MockStore_WorkflowUpdate_Call) Return(err error) *MockStore_WorkflowUpdate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_WorkflowUpdate_Call) RunAndReturn(run func(workflow *model.Workflow) error) *MockStore_WorkflowUpdate_Call { _c.Call.Return(run) return _c } // WorkflowsCreate provides a mock function for the type MockStore func (_mock *MockStore) WorkflowsCreate(workflows []*model.Workflow) error { ret := _mock.Called(workflows) if len(ret) == 0 { panic("no return value specified for WorkflowsCreate") } var r0 error if returnFunc, ok := ret.Get(0).(func([]*model.Workflow) error); ok { r0 = returnFunc(workflows) } else { r0 = ret.Error(0) } return r0 } // MockStore_WorkflowsCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowsCreate' type MockStore_WorkflowsCreate_Call struct { *mock.Call } // WorkflowsCreate is a helper method to define mock.On call // - workflows []*model.Workflow func (_e *MockStore_Expecter) WorkflowsCreate(workflows interface{}) *MockStore_WorkflowsCreate_Call { return &MockStore_WorkflowsCreate_Call{Call: _e.mock.On("WorkflowsCreate", workflows)} } func (_c *MockStore_WorkflowsCreate_Call) Run(run func(workflows []*model.Workflow)) *MockStore_WorkflowsCreate_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 []*model.Workflow if args[0] != nil { arg0 = args[0].([]*model.Workflow) } run( arg0, ) }) return _c } func (_c *MockStore_WorkflowsCreate_Call) Return(err error) *MockStore_WorkflowsCreate_Call { _c.Call.Return(err) return _c } func (_c *MockStore_WorkflowsCreate_Call) RunAndReturn(run func(workflows []*model.Workflow) error) *MockStore_WorkflowsCreate_Call { _c.Call.Return(run) return _c } // WorkflowsReplace provides a mock function for the type MockStore func (_mock *MockStore) WorkflowsReplace(pipeline *model.Pipeline, workflows []*model.Workflow) error { ret := _mock.Called(pipeline, workflows) if len(ret) == 0 { panic("no return value specified for WorkflowsReplace") } var r0 error if returnFunc, ok := ret.Get(0).(func(*model.Pipeline, []*model.Workflow) error); ok { r0 = returnFunc(pipeline, workflows) } else { r0 = ret.Error(0) } return r0 } // MockStore_WorkflowsReplace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WorkflowsReplace' type MockStore_WorkflowsReplace_Call struct { *mock.Call } // WorkflowsReplace is a helper method to define mock.On call // - pipeline *model.Pipeline // - workflows []*model.Workflow func (_e *MockStore_Expecter) WorkflowsReplace(pipeline interface{}, workflows interface{}) *MockStore_WorkflowsReplace_Call { return &MockStore_WorkflowsReplace_Call{Call: _e.mock.On("WorkflowsReplace", pipeline, workflows)} } func (_c *MockStore_WorkflowsReplace_Call) Run(run func(pipeline *model.Pipeline, workflows []*model.Workflow)) *MockStore_WorkflowsReplace_Call { _c.Call.Run(func(args mock.Arguments) { var arg0 *model.Pipeline if args[0] != nil { arg0 = args[0].(*model.Pipeline) } var arg1 []*model.Workflow if args[1] != nil { arg1 = args[1].([]*model.Workflow) } run( arg0, arg1, ) }) return _c } func (_c *MockStore_WorkflowsReplace_Call) Return(err error) *MockStore_WorkflowsReplace_Call { _c.Call.Return(err) return _c } func (_c *MockStore_WorkflowsReplace_Call) RunAndReturn(run func(pipeline *model.Pipeline, workflows []*model.Workflow) error) *MockStore_WorkflowsReplace_Call { _c.Call.Return(run) return _c } ================================================ FILE: server/store/store.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import ( "context" "go.woodpecker-ci.org/woodpecker/v3/server/model" ) // TODO: CreateX func should return new object to not indirect let storage change an existing object (alter ID etc...) type Store interface { // Users // GetUser gets a user by unique ID. GetUser(int64) (*model.User, error) // GetUserByRemoteID gets a user by remote ID. GetUserByRemoteID(int64, model.ForgeRemoteID) (*model.User, error) // GetUserByLogin gets a user by its login name. GetUserByLogin(int64, string) (*model.User, error) // GetUserList gets a list of all users in the system. GetUserList(p *model.ListOptions) ([]*model.User, error) // GetUserCount gets a count of all users in the system. GetUserCount() (int64, error) // CreateUser creates a new user account. CreateUser(*model.User) error // UpdateUser updates a user account. UpdateUser(*model.User) error // DeleteUser deletes a user account. DeleteUser(*model.User) error // Repos // GetRepo gets a repo by unique ID. GetRepo(int64) (*model.Repo, error) // GetRepoForgeID gets a repo by its forge ID and forge remote ID. GetRepoForgeID(int64, model.ForgeRemoteID) (*model.Repo, error) // GetRepoNameFallback gets the repo by its forge ID and forge remote ID, and if this doesn't exist by its full name. GetRepoNameFallback(forgeID int64, remoteID model.ForgeRemoteID, fullName string) (*model.Repo, error) // GetRepoName gets a repo by its full name. GetRepoName(string) (*model.Repo, error) // GetRepoCount gets a count of all repositories in the system. GetRepoCount() (int64, error) // CreateRepo creates a new repository. CreateRepo(*model.Repo) error // UpdateRepo updates a user repository. UpdateRepo(*model.Repo) error // DeleteRepo deletes a user repository. DeleteRepo(*model.Repo) error // Redirections // CreateRedirection creates a redirection CreateRedirection(redirection *model.Redirection) error // HasRedirectionForRepo checks if there's a redirection for the given repo and full name HasRedirectionForRepo(int64, string) (bool, error) // Pipelines // GetPipeline gets a pipeline by unique ID. GetPipeline(int64) (*model.Pipeline, error) // GetPipelineNumber gets a pipeline by number. GetPipelineNumber(*model.Repo, int64) (*model.Pipeline, error) // GetPipelineBadge gets the last relevant pipeline for the badge. GetPipelineBadge(*model.Repo, string, []model.WebhookEvent) (*model.Pipeline, error) // GetPipelineLastByBranch gets the last pipeline for the branch. GetPipelineLastByBranch(*model.Repo, string) (*model.Pipeline, error) // GetPipelineLastBefore gets the last pipeline before pipeline number N. GetPipelineLastBefore(*model.Repo, string, int64) (*model.Pipeline, error) // GetPipelineList gets a list of pipelines for the repository GetPipelineList(*model.Repo, *model.ListOptions, *model.PipelineFilter) ([]*model.Pipeline, error) // GetRepoLatestPipelines gets the latest pipelines for the given repo IDs. GetRepoLatestPipelines([]int64) ([]*model.Pipeline, error) // GetActivePipelineList gets a list of the active pipelines for the repository GetActivePipelineList(repo *model.Repo) ([]*model.Pipeline, error) // GetPipelineQueue gets a list of pipelines in queue. GetPipelineQueue() ([]*model.Feed, error) // GetPipelineCount gets a count of all pipelines in the system. GetPipelineCount() (int64, error) // CreatePipeline creates a new pipeline and steps. CreatePipeline(*model.Pipeline, ...*model.Step) error // UpdatePipeline updates a pipeline. UpdatePipeline(*model.Pipeline) error // DeletePipeline deletes a pipeline. DeletePipeline(*model.Pipeline) error // Feeds UserFeed(*model.User) ([]*model.Feed, error) // Repositories RepoList(user *model.User, owned, active bool, filter *model.RepoFilter) ([]*model.Repo, error) RepoListLatest(*model.User) ([]*model.Feed, error) RepoListAll(active bool, p *model.ListOptions) ([]*model.Repo, error) // Permissions PermFind(user *model.User, repo *model.Repo) (*model.Perm, error) PermUpsert(perm *model.Perm) error PermPrune(userID int64, keepRepoIDs []int64) error // Configs ConfigsForPipeline(pipelineID int64) ([]*model.Config, error) ConfigPersist(*model.Config) (*model.Config, error) PipelineConfigCreate(*model.PipelineConfig) error // Secrets SecretFind(*model.Repo, string) (*model.Secret, error) SecretList(*model.Repo, bool, *model.ListOptions) ([]*model.Secret, error) SecretListAll() ([]*model.Secret, error) SecretCreate(*model.Secret) error SecretUpdate(*model.Secret) error SecretDelete(*model.Secret) error OrgSecretFind(int64, string) (*model.Secret, error) OrgSecretList(int64, *model.ListOptions) ([]*model.Secret, error) GlobalSecretFind(string) (*model.Secret, error) GlobalSecretList(*model.ListOptions) ([]*model.Secret, error) // Registries RegistryFind(*model.Repo, string) (*model.Registry, error) RegistryList(*model.Repo, bool, *model.ListOptions) ([]*model.Registry, error) RegistryListAll() ([]*model.Registry, error) RegistryCreate(*model.Registry) error RegistryUpdate(*model.Registry) error RegistryDelete(*model.Registry) error OrgRegistryFind(int64, string) (*model.Registry, error) OrgRegistryList(int64, *model.ListOptions) ([]*model.Registry, error) GlobalRegistryFind(string) (*model.Registry, error) GlobalRegistryList(*model.ListOptions) ([]*model.Registry, error) // Steps StepLoad(pipelineID, stepID int64) (*model.Step, error) StepByUUID(uuid string) (*model.Step, error) StepList(pipelineID int64) ([]*model.Step, error) StepUpdate(*model.Step) error StepListFromWorkflowFind(*model.Workflow) ([]*model.Step, error) // Logs LogFind(*model.Step) ([]*model.LogEntry, error) LogAppend(*model.Step, []*model.LogEntry) error LogDelete(*model.Step) error StepFinished(*model.Step) // Tasks // TaskList TODO: paginate & opt filter TaskList() ([]*model.Task, error) TaskInsert(*model.Task) error TaskDelete(string) error // ServerConfig ServerConfigGet(string) (string, error) ServerConfigSet(string, string) error ServerConfigDelete(string) error // Cron CronCreate(*model.Cron) error CronFind(*model.Repo, int64) (*model.Cron, error) CronList(*model.Repo, *model.ListOptions) ([]*model.Cron, error) CronUpdate(*model.Repo, *model.Cron) error CronDelete(*model.Repo, int64) error CronListNextExecute(int64, int64) ([]*model.Cron, error) CronGetLock(*model.Cron, int64) (bool, error) // Forge ForgeCreate(*model.Forge) error ForgeGet(int64) (*model.Forge, error) ForgeList(p *model.ListOptions) ([]*model.Forge, error) ForgeUpdate(*model.Forge) error ForgeDelete(*model.Forge) error // Agent AgentCreate(*model.Agent) error AgentFind(int64) (*model.Agent, error) AgentFindByToken(string) (*model.Agent, error) AgentList(p *model.ListOptions) ([]*model.Agent, error) AgentUpdate(*model.Agent) error AgentDelete(*model.Agent) error AgentListForOrg(orgID int64, opt *model.ListOptions) ([]*model.Agent, error) // Workflow WorkflowGetTree(*model.Pipeline) ([]*model.Workflow, error) WorkflowsCreate([]*model.Workflow) error WorkflowsReplace(*model.Pipeline, []*model.Workflow) error WorkflowLoad(int64) (*model.Workflow, error) WorkflowUpdate(*model.Workflow) error // Org OrgCreate(*model.Org) error OrgGet(int64) (*model.Org, error) OrgFindByName(string, int64) (*model.Org, error) OrgUpdate(*model.Org) error OrgDelete(int64) error OrgList(*model.ListOptions) ([]*model.Org, error) // Org repos OrgRepoList(*model.Org, *model.ListOptions) ([]*model.Repo, error) // Store operations Ping() error Close() error Migrate(context.Context, bool) error } ================================================ FILE: server/store/types/errors.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "database/sql" "errors" ) var ( // RecordNotExist a Get or Update could not find the requested record. ErrRecordNotExist = sql.ErrNoRows // ErrInsertDuplicateDetected is returned when an insert fails because of unique constrains. ErrInsertDuplicateDetected = errors.New("on insert duplicate based on constraints was detected") ) ================================================ FILE: server/web/config.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package web import ( "encoding/json" "net/http" "strconv" "text/template" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v3/shared/token" "go.woodpecker-ci.org/woodpecker/v3/version" ) func Config(c *gin.Context) { user := session.User(c) var csrf string if user != nil { t := token.New(token.CsrfToken) t.Set("user-id", strconv.FormatInt(user.ID, 10)) csrf, _ = t.Sign(user.Hash) } configData := map[string]any{ "user": user, "csrf": csrf, "version": version.String(), "skip_version_check": server.Config.WebUI.SkipVersionCheck, "root_path": server.Config.Server.RootPath, "enable_swagger": server.Config.WebUI.EnableSwagger, "user_registered_agents": !server.Config.Agent.DisableUserRegisteredAgentRegistration, "max_pipeline_log_line_count": server.Config.WebUI.MaxPipelineLogLineCount, } // default func map with json parser. funcMap := template.FuncMap{ "json": func(v any) string { a, err := json.Marshal(v) if err != nil { log.Error().Err(err).Msg("could not marshal JSON") return "" } return string(a) }, } c.Header("Content-Type", "text/javascript; charset=utf-8") tmpl := template.Must(template.New("").Funcs(funcMap).Parse(configTemplate)) if err := tmpl.Execute(c.Writer, configData); err != nil { log.Error().Err(err).Msg("could not execute template") c.AbortWithStatus(http.StatusInternalServerError) return } c.Status(http.StatusOK) } const configTemplate = ` window.WOODPECKER_USER = {{ json .user }}; window.WOODPECKER_CSRF = "{{ .csrf }}"; window.WOODPECKER_VERSION = "{{ .version }}"; window.WOODPECKER_ROOT_PATH = "{{ .root_path }}"; window.WOODPECKER_ENABLE_SWAGGER = {{ .enable_swagger }}; window.WOODPECKER_SKIP_VERSION_CHECK = {{ .skip_version_check }} window.WOODPECKER_USER_REGISTERED_AGENTS = {{ .user_registered_agents }} window.WOODPECKER_MAX_PIPELINE_LOG_LINE_COUNT = {{ .max_pipeline_log_line_count }} ` ================================================ FILE: server/web/web.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package web import ( "bytes" "errors" "fmt" "io" "io/fs" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/rs/zerolog/log" "go.woodpecker-ci.org/woodpecker/v3/server" "go.woodpecker-ci.org/woodpecker/v3/web" ) var indexHTML []byte type prefixFS struct { fs http.FileSystem prefix string } func (f *prefixFS) Open(name string) (http.File, error) { return f.fs.Open(strings.TrimPrefix(name, f.prefix)) } // New returns a gin engine to serve the web frontend. func New() (*gin.Engine, error) { e := gin.New() var err error indexHTML, err = parseIndex() if err != nil { return nil, err } rootPath := server.Config.Server.RootPath httpFS, err := web.HTTPFS() if err != nil { return nil, err } f := &prefixFS{httpFS, rootPath} e.GET(rootPath+"/favicon.svg", redirect(server.Config.Server.RootPath+"/favicons/favicon-light-default.svg", http.StatusPermanentRedirect)) e.GET(rootPath+"/favicons/*filepath", serveFile(f)) e.GET(rootPath+"/assets/*filepath", handleCustomFilesAndAssets(f)) e.NoRoute(handleIndex) return e, nil } func handleCustomFilesAndAssets(fs *prefixFS) func(ctx *gin.Context) { serveFileOrEmptyContent := func(w http.ResponseWriter, r *http.Request, localFileName, fileName string) { if len(localFileName) > 0 { http.ServeFile(w, r, localFileName) } else { // prefer zero content over sending a 404 Not Found http.ServeContent(w, r, fileName, time.Now(), bytes.NewReader([]byte{})) } } return func(ctx *gin.Context) { switch { case strings.HasSuffix(ctx.Request.RequestURI, "/assets/custom.js"): serveFileOrEmptyContent(ctx.Writer, ctx.Request, server.Config.Server.CustomJsFile, "file.js") case strings.HasSuffix(ctx.Request.RequestURI, "/assets/custom.css"): serveFileOrEmptyContent(ctx.Writer, ctx.Request, server.Config.Server.CustomCSSFile, "file.css") default: serveFile(fs)(ctx) } } } func serveFile(f *prefixFS) func(ctx *gin.Context) { return func(ctx *gin.Context) { file, err := f.Open(ctx.Request.URL.Path) if err != nil { code := http.StatusInternalServerError if errors.Is(err, fs.ErrNotExist) { code = http.StatusNotFound } else if errors.Is(err, fs.ErrPermission) { code = http.StatusForbidden } ctx.Status(code) return } data, err := io.ReadAll(file) if err != nil { ctx.Status(http.StatusInternalServerError) return } var mime string switch { case strings.HasSuffix(ctx.Request.URL.Path, ".js"): mime = "text/javascript" case strings.HasSuffix(ctx.Request.URL.Path, ".css"): mime = "text/css" case strings.HasSuffix(ctx.Request.URL.Path, ".png"): mime = "image/png" case strings.HasSuffix(ctx.Request.URL.Path, ".svg"): mime = "image/svg+xml" } ctx.Status(http.StatusOK) ctx.Writer.Header().Set("Cache-Control", "public, max-age=31536000") ctx.Writer.Header().Del("Expires") ctx.Writer.Header().Set("Content-Type", mime) if _, err := ctx.Writer.Write(replaceBytes(data)); err != nil { log.Error().Err(err).Msgf("cannot write %s", ctx.Request.URL.Path) } } } // redirect return gin helper to redirect a request. func redirect(location string, status ...int) func(ctx *gin.Context) { return func(ctx *gin.Context) { code := http.StatusFound if len(status) == 1 { code = status[0] } http.Redirect(ctx.Writer, ctx.Request, location, code) } } func handleIndex(c *gin.Context) { rw := c.Writer rw.Header().Set("Cache-Control", "no-cache") rw.Header().Set("Content-Type", "text/html; charset=UTF-8") rw.WriteHeader(http.StatusOK) if _, err := rw.Write(indexHTML); err != nil { log.Error().Err(err).Msg("cannot write index.html") } } func loadFile(path string) ([]byte, error) { data, err := web.Lookup(path) if err != nil { return nil, err } return replaceBytes(data), nil } func replaceBytes(data []byte) []byte { return bytes.ReplaceAll(data, []byte("/BASE_PATH"), []byte(server.Config.Server.RootPath)) } func parseIndex() ([]byte, error) { data, err := loadFile("index.html") if err != nil { return nil, fmt.Errorf("cannot find index.html: %w", err) } data = bytes.ReplaceAll(data, []byte("/web-config.js"), []byte(server.Config.Server.RootPath+"/web-config.js")) data = bytes.ReplaceAll(data, []byte("/assets/custom.css"), []byte(server.Config.Server.RootPath+"/assets/custom.css")) data = bytes.ReplaceAll(data, []byte("/assets/custom.js"), []byte(server.Config.Server.RootPath+"/assets/custom.js")) return data, nil } ================================================ FILE: server/web/web_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package web import ( "net/http" "net/http/httptest" "os" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/server" ) func Test_custom_file_returns_OK_and_empty_content_and_fitting_mimetype(t *testing.T) { gin.SetMode(gin.TestMode) filesToTest := []struct { fileURL string shortMimetype string }{ { fileURL: "/assets/custom.js", shortMimetype: "javascript", // using just the short version, since it depends on the go runtime/version }, { fileURL: "/assets/custom.css", shortMimetype: "css", // using just the short version, since it depends on the go runtime/version }, } for _, f := range filesToTest { t.Run(f.fileURL, func(t *testing.T) { request, err := http.NewRequest(http.MethodGet, f.fileURL, nil) request.RequestURI = f.fileURL // additional required for mocking assert.NoError(t, err) rr := httptest.NewRecorder() router, _ := New() router.ServeHTTP(rr, request) assert.Equal(t, 200, rr.Code) assert.Equal(t, []byte(nil), rr.Body.Bytes()) assert.Contains(t, rr.Header().Get("Content-Type"), f.shortMimetype) }) } } func Test_custom_file_return_actual_content(t *testing.T) { gin.SetMode(gin.TestMode) temp, err := os.CreateTemp(os.TempDir(), "data.txt") assert.NoError(t, err) _, err = temp.Write([]byte("EXPECTED-DATA")) assert.NoError(t, err) err = temp.Close() assert.NoError(t, err) server.Config.Server.CustomJsFile = temp.Name() server.Config.Server.CustomCSSFile = temp.Name() customRequestedFilesToTest := []string{ "/assets/custom.js", "/assets/custom.css", } for _, f := range customRequestedFilesToTest { t.Run(f, func(t *testing.T) { request, err := http.NewRequest(http.MethodGet, f, nil) request.RequestURI = f // additional required for mocking assert.NoError(t, err) rr := httptest.NewRecorder() router, _ := New() router.ServeHTTP(rr, request) assert.Equal(t, 200, rr.Code) assert.Equal(t, []byte("EXPECTED-DATA"), rr.Body.Bytes()) }) } } ================================================ FILE: shared/constant/constant.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package constant import "time" // DefaultConfigOrder represent the priority in witch woodpecker search for a pipeline config by default // folders are indicated by supplying a trailing slash. var DefaultConfigOrder = [...]string{ ".woodpecker/", ".woodpecker.yaml", ".woodpecker.yml", } const ( // DefaultClonePlugin can be changed by 'WOODPECKER_DEFAULT_CLONE_PLUGIN' at runtime. // renovate: datasource=docker depName=woodpeckerci/plugin-git DefaultClonePlugin = "docker.io/woodpeckerci/plugin-git:2.9.0" ) // TrustedClonePlugins can be changed by 'WOODPECKER_PLUGINS_TRUSTED_CLONE' at runtime. var TrustedClonePlugins = []string{ DefaultClonePlugin, "docker.io/woodpeckerci/plugin-git", "quay.io/woodpeckerci/plugin-git", } // TaskTimeout is the time till a running task is counted as dead. var TaskTimeout = time.Minute ================================================ FILE: shared/httputil/http_error.go ================================================ // Copyright 2025 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package httputil import ( "context" "crypto/x509" "errors" "fmt" "net" "net/url" "os" "strings" "syscall" ) // EnhanceHTTPError adds detailed context to HTTP errors to help with debugging. func EnhanceHTTPError(err error, method, endpoint string) error { if err == nil { return nil } // parse url to get host information parsedURL, parseErr := url.Parse(endpoint) var host string if parseErr == nil { host = parsedURL.Host } else { host = endpoint } // base error message baseMsg := fmt.Sprintf("%s %q", method, endpoint) // check for context errors // timeout if errors.Is(err, context.DeadlineExceeded) { if strings.Contains(err.Error(), "Client.Timeout") { return fmt.Errorf("connection timeout: %s: %w (the remote server at %s did not respond within the configured timeout)", baseMsg, err, host) } return fmt.Errorf("request timeout: %s: %w (operation took too long time)", baseMsg, err) } // cancellation if errors.Is(err, context.Canceled) { return fmt.Errorf("request canceled: %s: %w (the operation was canceled before completion)", baseMsg, err) } // check for net package errors // dns error handling var dnsErr *net.DNSError if errors.As(err, &dnsErr) { if dnsErr.IsNotFound { return fmt.Errorf("DNS resolution failed: %s: %w (hostname %s does not exist or cannot be resolved)", baseMsg, err, host) } if dnsErr.IsTimeout { return fmt.Errorf("DNS timeout: %s: %w (DNS server did not respond in time)", baseMsg, err) } return fmt.Errorf("DNS error: %s: %w", baseMsg, err) } // op error handling var opErr *net.OpError if errors.As(err, &opErr) { // connection refused if errors.Is(opErr.Err, syscall.ECONNREFUSED) { return fmt.Errorf("connection refused: %s: %w (server at %s is not accepting connections - is it running?)", baseMsg, err, host) } // connection reset if errors.Is(opErr.Err, syscall.ECONNRESET) { return fmt.Errorf("connection reset: %s: %w (server at %s closed the connection unexpectedly)", baseMsg, err, host) } // network unreachable if errors.Is(opErr.Err, syscall.ENETUNREACH) { return fmt.Errorf("network unreachable: %s: %w (cannot reach %s - check network connectivity)", baseMsg, err, host) } // host unreachable if errors.Is(opErr.Err, syscall.EHOSTUNREACH) { return fmt.Errorf("host unreachable: %s: %w (cannot reach %s - host may be down or firewall blocking)", baseMsg, err, host) } // timeout during operation if opErr.Timeout() { return fmt.Errorf("network timeout during %s: %s: %w (operation at %s took too long)", opErr.Op, baseMsg, err, host) } return fmt.Errorf("network error during %s: %s: %w", opErr.Op, baseMsg, err) } // check for url parsing errors var urlErr *url.Error if errors.As(err, &urlErr) { return fmt.Errorf("URL error: %s: %w (check if the endpoint URL is correctly formatted)", baseMsg, err) } // check for TLS/certificate errors var certErr *x509.CertificateInvalidError if errors.As(err, &certErr) { return fmt.Errorf("TLS certificate invalid: %s: %w (certificate validation failed for %s)", baseMsg, err, host) } var unknownAuthErr *x509.UnknownAuthorityError if errors.As(err, &unknownAuthErr) { return fmt.Errorf("TLS certificate verification failed: %s: %w (certificate signed by unknown authority for %s)", baseMsg, err, host) } var hostErr *x509.HostnameError if errors.As(err, &hostErr) { return fmt.Errorf("TLS hostname mismatch: %s: %w (certificate is not valid for %s)", baseMsg, err, host) } // check for os errors if errors.Is(err, os.ErrInvalid) { return fmt.Errorf("invalid argument: %s: %w", baseMsg, err) } if errors.Is(err, os.ErrPermission) { return fmt.Errorf("permission denied: %s: %w", baseMsg, err) } if errors.Is(err, os.ErrExist) { return fmt.Errorf("file already exists: %s: %w", baseMsg, err) } if errors.Is(err, os.ErrNotExist) { return fmt.Errorf("file does not exist: %s: %w", baseMsg, err) } if errors.Is(err, os.ErrClosed) { return fmt.Errorf("file already closed: %s: %w", baseMsg, err) } if errors.Is(err, os.ErrNoDeadline) { return fmt.Errorf("file type does not support deadline: %s: %w", baseMsg, err) } if errors.Is(err, os.ErrDeadlineExceeded) { return fmt.Errorf("i/o timeout: %s: %w", baseMsg, err) } // check for EOF specifically if err.Error() == "EOF" || strings.Contains(err.Error(), "EOF") { return fmt.Errorf("unexpected connection closure: %s: %w (server at %s closed connection prematurely - possible causes: server crash, request too large, incompatible protocol, or server-side timeout)", baseMsg, err, host) } // check for "connection reset by peer" if strings.Contains(err.Error(), "connection reset by peer") { return fmt.Errorf("connection reset by peer: %s: %w (server at %s forcibly closed the connection)", baseMsg, err, host) } // generic error return fmt.Errorf("HTTP request failed: %s: %w", baseMsg, err) } ================================================ FILE: shared/httputil/http_error_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package httputil import ( "context" "crypto/x509" "errors" "io" "net" "syscall" "testing" ) // TestEnhanceHTTPError tests the enhanceHTTPError function with various error types. func TestEnhanceHTTPError(t *testing.T) { tests := []struct { name string err error method string endpoint string want string }{ { name: "nil error", err: nil, method: "POST", endpoint: "https://example.com", want: "", }, { name: "context deadline exceeded", err: context.DeadlineExceeded, method: "POST", endpoint: "https://example.com/api", want: "request timeout", }, { name: "context canceled", err: context.Canceled, method: "GET", endpoint: "https://example.com/api", want: "request canceled", }, { name: "DNS not found error", err: &net.DNSError{ Err: "no such host", IsNotFound: true, IsTimeout: false, IsTemporary: false, }, method: "POST", endpoint: "https://nonexistent.example.com", want: "DNS resolution failed", }, { name: "DNS timeout error", err: &net.DNSError{ Err: "timeout", IsTimeout: true, IsTemporary: true, }, method: "POST", endpoint: "https://example.com", want: "DNS timeout", }, { name: "unknown authority certificate error", err: x509.UnknownAuthorityError{}, method: "POST", endpoint: "https://self-signed.example.com", want: "TLS certificate verification failed", }, { name: "connection refused", err: &net.OpError{ Op: "dial", Err: syscall.ECONNREFUSED, }, method: "POST", endpoint: "https://localhost:9999", want: "connection refused", }, { name: "connection reset", err: &net.OpError{ Op: "read", Err: syscall.ECONNRESET, }, method: "POST", endpoint: "https://example.com", want: "connection reset", }, { name: "EOF error", err: io.EOF, method: "POST", endpoint: "https://example.com/api", want: "unexpected connection closure", }, { name: "generic error", err: errors.New("some random error"), method: "POST", endpoint: "https://example.com", want: "HTTP request failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := EnhanceHTTPError(tt.err, tt.method, tt.endpoint) if tt.want == "" { if got != nil { t.Errorf("enhanceHTTPError() = %v, want nil", got) } return } if got == nil { t.Errorf("enhanceHTTPError() = nil, want error containing %q", tt.want) return } if got.Error() == "" { t.Errorf("enhanceHTTPError() returned empty error message") return } // check empty error message errMsg := got.Error() if len(errMsg) == 0 { t.Errorf("enhanceHTTPError() returned empty error string") return } // view the enhance error message t.Logf("enhanced error: %v", errMsg) }) } } func TestEnhanceHTTPErrorPreservesOriginal(t *testing.T) { originalErr := io.EOF endpoint := "https://example.com/api" enhanced := EnhanceHTTPError(originalErr, "POST", endpoint) // the io.EOF error should be wrapped inside the enhanced error if !errors.Is(enhanced, originalErr) { t.Errorf("enhanced error should wrap original error, but errors.Is returned false") } } ================================================ FILE: shared/httputil/httputil.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package httputil import ( "math" "net/http" "strings" ) // IsHTTPS is a helper function that evaluates the http.Request // and returns True if the Request uses HTTPS. It is able to detect, // using the X-Forwarded-Proto, if the original request was HTTPS and // routed through a reverse proxy with SSL termination. func IsHTTPS(r *http.Request) bool { switch { case r.URL.Scheme == "https": return true case r.TLS != nil: return true case strings.HasPrefix(r.Proto, "HTTPS"): return true case r.Header.Get("X-Forwarded-Proto") == "https": return true default: return false } } // SetCookie writes the cookie value. func SetCookie(w http.ResponseWriter, r *http.Request, name, value string) { cookie := http.Cookie{ Name: name, Value: value, Path: "/", Domain: r.URL.Host, HttpOnly: true, Secure: IsHTTPS(r), MaxAge: math.MaxInt32, // the cookie value (token) is responsible for expiration } http.SetCookie(w, &cookie) } // DelCookie deletes a cookie. func DelCookie(w http.ResponseWriter, r *http.Request, name string) { cookie := http.Cookie{ Name: name, Value: "deleted", Path: "/", Domain: r.URL.Host, MaxAge: -1, } http.SetCookie(w, &cookie) } ================================================ FILE: shared/httputil/useragent.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package httputil import ( "fmt" "net/http" "go.woodpecker-ci.org/woodpecker/v3/version" ) // UserAgentRoundTripper is an http.RoundTripper that sets a custom User-Agent header // on all outgoing requests. type UserAgentRoundTripper struct { base http.RoundTripper userAgent string } // NewUserAgentRoundTripper creates a new RoundTripper that adds the Woodpecker User-Agent // to all requests. If base is nil, http.DefaultTransport is used. func NewUserAgentRoundTripper(base http.RoundTripper, component string) *UserAgentRoundTripper { if base == nil { base = http.DefaultTransport } userAgent := fmt.Sprintf("Woodpecker/%s", version.String()) if component != "" { userAgent = fmt.Sprintf("%s (%s)", userAgent, component) } return &UserAgentRoundTripper{ base: base, userAgent: userAgent, } } // RoundTrip implements the http.RoundTripper interface. func (rt *UserAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original reqClone := req.Clone(req.Context()) // Set the User-Agent header if not already set if reqClone.Header.Get("User-Agent") == "" { reqClone.Header.Set("User-Agent", rt.userAgent) } // Execute the request using the base transport return rt.base.RoundTrip(reqClone) } // WrapClient wraps an existing http.Client with the UserAgentRoundTripper. // If client is nil, a new client with default settings is created. func WrapClient(client *http.Client, component string) *http.Client { if client == nil { client = &http.Client{} } client.Transport = NewUserAgentRoundTripper(client.Transport, component) return client } ================================================ FILE: shared/httputil/useragent_test.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package httputil import ( "fmt" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/version" ) func TestNewUserAgentRoundTripper(t *testing.T) { t.Run("with custom component", func(t *testing.T) { rt := NewUserAgentRoundTripper(nil, "test-component") assert.NotNil(t, rt) assert.NotNil(t, rt.base) expectedUA := fmt.Sprintf("Woodpecker/%s (test-component)", version.String()) assert.Equal(t, expectedUA, rt.userAgent) }) t.Run("without component", func(t *testing.T) { rt := NewUserAgentRoundTripper(nil, "") assert.NotNil(t, rt) expectedUA := fmt.Sprintf("Woodpecker/%s", version.String()) assert.Equal(t, expectedUA, rt.userAgent) }) t.Run("with custom base transport", func(t *testing.T) { customTransport := &http.Transport{} rt := NewUserAgentRoundTripper(customTransport, "custom") assert.Equal(t, customTransport, rt.base) }) } func TestUserAgentRoundTripper_RoundTrip(t *testing.T) { // Create a test server to capture requests var capturedUserAgent string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedUserAgent = r.Header.Get("User-Agent") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) })) defer server.Close() t.Run("sets user-agent when not present", func(t *testing.T) { client := &http.Client{ Transport: NewUserAgentRoundTripper(nil, "agent"), } req, err := http.NewRequest(http.MethodGet, server.URL, nil) assert.NoError(t, err) resp, err := client.Do(req) assert.NoError(t, err) assert.NotNil(t, resp) defer resp.Body.Close() expectedUA := fmt.Sprintf("Woodpecker/%s (agent)", version.String()) assert.Equal(t, expectedUA, capturedUserAgent) }) t.Run("preserves existing user-agent", func(t *testing.T) { client := &http.Client{ Transport: NewUserAgentRoundTripper(nil, "agent"), } customUA := "CustomUserAgent/1.0" req, err := http.NewRequest(http.MethodGet, server.URL, nil) assert.NoError(t, err) req.Header.Set("User-Agent", customUA) resp, err := client.Do(req) assert.NoError(t, err) assert.NotNil(t, resp) defer resp.Body.Close() assert.Equal(t, customUA, capturedUserAgent) }) t.Run("does not modify original request", func(t *testing.T) { client := &http.Client{ Transport: NewUserAgentRoundTripper(nil, "test"), } req, err := http.NewRequest(http.MethodGet, server.URL, nil) assert.NoError(t, err) originalUserAgent := req.Header.Get("User-Agent") resp, err := client.Do(req) assert.NoError(t, err) assert.NotNil(t, resp) defer resp.Body.Close() // Original request should remain unchanged assert.Equal(t, originalUserAgent, req.Header.Get("User-Agent")) }) } func TestWrapClient(t *testing.T) { t.Run("wraps existing client", func(t *testing.T) { originalClient := &http.Client{} wrappedClient := WrapClient(originalClient, "cli") assert.Equal(t, originalClient, wrappedClient) assert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport) }) t.Run("creates new client when nil", func(t *testing.T) { wrappedClient := WrapClient(nil, "server") assert.NotNil(t, wrappedClient) assert.IsType(t, &UserAgentRoundTripper{}, wrappedClient.Transport) }) t.Run("preserves existing transport", func(t *testing.T) { customTransport := &http.Transport{} originalClient := &http.Client{ Transport: customTransport, } wrappedClient := WrapClient(originalClient, "test") rt, ok := wrappedClient.Transport.(*UserAgentRoundTripper) assert.True(t, ok) assert.Equal(t, customTransport, rt.base) }) } func TestIntegration_UserAgentInRealRequest(t *testing.T) { // Test with a real HTTP server var receivedHeaders http.Header server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { receivedHeaders = r.Header.Clone() w.WriteHeader(http.StatusOK) })) defer server.Close() client := WrapClient(nil, "integration-test") req, err := http.NewRequest(http.MethodGet, server.URL, nil) assert.NoError(t, err) resp, err := client.Do(req) assert.NoError(t, err) assert.NotNil(t, resp) defer resp.Body.Close() userAgent := receivedHeaders.Get("User-Agent") assert.NotEmpty(t, userAgent) assert.Contains(t, userAgent, "Woodpecker/") assert.Contains(t, userAgent, "(integration-test)") } ================================================ FILE: shared/logger/addon_logger.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logger import ( "bytes" "io" std_log "log" "github.com/hashicorp/go-hclog" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) type AddonClientLogger struct { Logger zerolog.Logger name string withArgs []any } // cspell:words hclog func convertLvl(level hclog.Level) zerolog.Level { switch level { case hclog.Error: return zerolog.ErrorLevel case hclog.Warn: return zerolog.WarnLevel case hclog.Info: return zerolog.InfoLevel case hclog.Debug: return zerolog.DebugLevel case hclog.Trace: return zerolog.TraceLevel } return zerolog.NoLevel } func (c *AddonClientLogger) applyArgs(args []any) *zerolog.Logger { var key string logger := c.Logger.With() args = append(args, c.withArgs) for i, arg := range args { switch { case key != "": logger.Any(key, arg) key = "" case i == len(args)-1: logger.Any(hclog.MissingKey, arg) default: key, _ = arg.(string) } } l := logger.Logger() return &l } func (c *AddonClientLogger) Log(level hclog.Level, msg string, args ...any) { c.applyArgs(args).WithLevel(convertLvl(level)).Msg(msg) } func (c *AddonClientLogger) Trace(msg string, args ...any) { c.applyArgs(args).Trace().Msg(msg) } func (c *AddonClientLogger) Debug(msg string, args ...any) { c.applyArgs(args).Debug().Msg(msg) } func (c *AddonClientLogger) Info(msg string, args ...any) { c.applyArgs(args).Info().Msg(msg) } func (c *AddonClientLogger) Warn(msg string, args ...any) { c.applyArgs(args).Warn().Msg(msg) } func (c *AddonClientLogger) Error(msg string, args ...any) { c.applyArgs(args).Error().Msg(msg) } func (c *AddonClientLogger) IsTrace() bool { return log.Logger.GetLevel() >= zerolog.TraceLevel } func (c *AddonClientLogger) IsDebug() bool { return log.Logger.GetLevel() >= zerolog.DebugLevel } func (c *AddonClientLogger) IsInfo() bool { return log.Logger.GetLevel() >= zerolog.InfoLevel } func (c *AddonClientLogger) IsWarn() bool { return log.Logger.GetLevel() >= zerolog.WarnLevel } func (c *AddonClientLogger) IsError() bool { return log.Logger.GetLevel() >= zerolog.ErrorLevel } func (c *AddonClientLogger) ImpliedArgs() []any { return c.withArgs } func (c *AddonClientLogger) With(args ...any) hclog.Logger { return &AddonClientLogger{ Logger: c.Logger, name: c.name, withArgs: args, } } func (c *AddonClientLogger) Name() string { return c.name } func (c *AddonClientLogger) Named(name string) hclog.Logger { curr := c.name if curr != "" { curr = c.name + "." } return c.ResetNamed(curr + name) } func (c *AddonClientLogger) ResetNamed(name string) hclog.Logger { return &AddonClientLogger{ Logger: c.Logger, name: name, withArgs: c.withArgs, } } func (c *AddonClientLogger) SetLevel(level hclog.Level) { c.Logger = c.Logger.Level(convertLvl(level)) } func (c *AddonClientLogger) GetLevel() hclog.Level { switch c.Logger.GetLevel() { case zerolog.ErrorLevel: return hclog.Error case zerolog.WarnLevel: return hclog.Warn case zerolog.InfoLevel: return hclog.Info case zerolog.DebugLevel: return hclog.Debug case zerolog.TraceLevel: return hclog.Trace } return hclog.NoLevel } func (c *AddonClientLogger) StandardLogger(opts *hclog.StandardLoggerOptions) *std_log.Logger { return std_log.New(c.StandardWriter(opts), "", 0) } func (c *AddonClientLogger) StandardWriter(*hclog.StandardLoggerOptions) io.Writer { return ioAdapter{logger: c.Logger} } type ioAdapter struct { logger zerolog.Logger } func (i ioAdapter) Write(p []byte) (n int, err error) { str := string(bytes.TrimRight(p, " \t\n")) i.logger.Log().Msg(str) return len(p), nil } ================================================ FILE: shared/logger/logger.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logger import ( "context" "fmt" "io" "os" "github.com/6543/logfile-open" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/urfave/cli/v3" ) var GlobalLoggerFlags = []cli.Flag{ &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_LOG_LEVEL"), Name: "log-level", Usage: "set logging level", Value: "info", }, &cli.StringFlag{ Sources: cli.EnvVars("WOODPECKER_LOG_FILE"), Name: "log-file", Usage: "Output destination for logs. 'stdout' and 'stderr' can be used as special keywords.", Value: "stderr", }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DEBUG_PRETTY"), Name: "pretty", Usage: "enable pretty-printed debug output", Value: isInteractiveTerminal(), // make pretty on interactive terminal by default }, &cli.BoolFlag{ Sources: cli.EnvVars("WOODPECKER_DEBUG_NOCOLOR"), Name: "nocolor", Usage: "disable colored debug output, only has effect if pretty output is set too", Value: !isInteractiveTerminal(), // do color on interactive terminal by default }, } func SetupGlobalLogger(ctx context.Context, c *cli.Command, outputLvl bool) error { logLevel := c.String("log-level") pretty := c.Bool("pretty") noColor := c.Bool("nocolor") logFile := c.String("log-file") var file io.ReadWriteCloser switch logFile { case "", "stderr": // default case file = os.Stderr case "stdout": file = os.Stdout default: // a file was set openFile, err := logfile.OpenFileWithContext(ctx, logFile, 0o660) if err != nil { return fmt.Errorf("could not open log file '%s': %w", logFile, err) } file = openFile noColor = true } log.Logger = zerolog.New(file).With().Timestamp().Logger() if pretty { log.Logger = log.Output( zerolog.ConsoleWriter{ Out: file, NoColor: noColor, }, ) } // TODO: format output & options to switch to json aka. option to add channels to send logs to lvl, err := zerolog.ParseLevel(logLevel) if err != nil { return fmt.Errorf("unknown logging level: %s", logLevel) } zerolog.SetGlobalLevel(lvl) // if debug or trace also log the caller if zerolog.GlobalLevel() <= zerolog.DebugLevel { log.Logger = log.With().Caller().Logger() } if outputLvl { log.Info().Msgf("log level: %s", zerolog.GlobalLevel().String()) } return nil } ================================================ FILE: shared/logger/terminal.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package logger import ( "os" "golang.org/x/term" ) // isInteractiveTerminal checks if the output is piped, but NOT if the session is run interactively. func isInteractiveTerminal() bool { return term.IsTerminal(int(os.Stdout.Fd())) } ================================================ FILE: shared/optional/option.go ================================================ // Copyright 2025 Woodpecker Authors. // Copyright 2024 The Gitea Authors. // // Licensed under the MIT License. package optional import "reflect" type Option[T any] []T func None[T any]() Option[T] { return nil } func Some[T any](v T) Option[T] { return Option[T]{v} } func FromPtr[T any](v *T) Option[T] { if v == nil { return None[T]() } return Some(*v) } func FromNonDefault[T comparable](v T) Option[T] { var zero T if v == zero { return None[T]() } return Some(v) } func (o Option[T]) Has() bool { return o != nil } func (o Option[T]) Value() T { var zero T return o.ValueOrDefault(zero) } func (o Option[T]) ValueOrDefault(v T) T { if o.Has() { return o[0] } return v } func (o Option[T]) ToPtr() *T { if o.Has() { return &o[0] } return nil } // ExtractValue return value or nil and bool if object was an Optional // it should only be used if you already have to deal with interface{} values // and expect an Option type within it. func ExtractValue(obj any) (any, bool) { rt := reflect.TypeOf(obj) if rt.Kind() != reflect.Slice { return nil, false } type hasHasFunc interface { Has() bool } if hasObj, ok := obj.(hasHasFunc); !ok { return nil, false } else if !hasObj.Has() { return nil, true } rv := reflect.ValueOf(obj) if rv.Len() != 1 { // it's still false as optional.Option[T] types would have reported with hasObj.Has() that it is empty return nil, false } return rv.Index(0).Interface(), true } ================================================ FILE: shared/optional/option_test.go ================================================ // Copyright 2025 Woodpecker Authors. // Copyright 2024 The Gitea Authors. // // Licensed under the MIT License. package optional_test import ( "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/shared/optional" ) func TestOption(t *testing.T) { var uninitialized optional.Option[int] assert.False(t, uninitialized.Has()) assert.Equal(t, int(0), uninitialized.Value()) assert.Equal(t, int(1), uninitialized.ValueOrDefault(1)) none := optional.None[int]() assert.False(t, none.Has()) assert.Equal(t, int(0), none.Value()) assert.Equal(t, int(1), none.ValueOrDefault(1)) some := optional.Some[int](1) assert.True(t, some.Has()) assert.Equal(t, int(1), some.Value()) assert.Equal(t, int(1), some.ValueOrDefault(2)) var ptr *int assert.False(t, optional.FromPtr(ptr).Has()) var boolPtr *bool assert.Equal(t, boolPtr, optional.None[bool]().ToPtr()) boolPtr = optional.Some[bool](false).ToPtr() assert.Equal(t, toPtr(false), boolPtr) opt1 := optional.FromPtr(toPtr(1)) assert.True(t, opt1.Has()) assert.Equal(t, int(1), opt1.Value()) assert.False(t, optional.FromNonDefault("").Has()) opt2 := optional.FromNonDefault("test") assert.True(t, opt2.Has()) assert.Equal(t, "test", opt2.Value()) assert.False(t, optional.FromNonDefault(0).Has()) opt3 := optional.FromNonDefault(1) assert.True(t, opt3.Has()) assert.Equal(t, int(1), opt3.Value()) } func TestExtractValue(t *testing.T) { val, ok := optional.ExtractValue("aaaa") assert.False(t, ok) assert.Nil(t, val) val, ok = optional.ExtractValue(optional.Some("aaaa")) assert.True(t, ok) if assert.NotNil(t, val) { val, ok := val.(string) assert.True(t, ok) assert.EqualValues(t, "aaaa", val) } val, ok = optional.ExtractValue(optional.None[float64]()) assert.True(t, ok) assert.Nil(t, val) val, ok = optional.ExtractValue(&fakeHas{}) assert.False(t, ok) assert.Nil(t, val) wrongType := make(fakeHas2, 0, 1) val, ok = optional.ExtractValue(wrongType) assert.False(t, ok) assert.Nil(t, val) } func toPtr[T any](val T) *T { return &val } type fakeHas struct{} func (fakeHas) Has() bool { return true } type fakeHas2 []string func (fakeHas2) Has() bool { return true } ================================================ FILE: shared/optional/serialization.go ================================================ // Copyright 2025 Woodpecker Authors. // Copyright 2024 "6543". // // Licensed under the MIT License. package optional import ( "encoding/json" "gopkg.in/yaml.v3" ) func (o *Option[T]) UnmarshalJSON(data []byte) error { var v *T if err := json.Unmarshal(data, &v); err != nil { return err } *o = FromPtr(v) return nil } func (o Option[T]) MarshalJSON() ([]byte, error) { if !o.Has() { return []byte("null"), nil } return json.Marshal(o.Value()) } func (o *Option[T]) UnmarshalYAML(value *yaml.Node) error { var v *T if err := value.Decode(&v); err != nil { return err } *o = FromPtr(v) return nil } func (o Option[T]) MarshalYAML() (any, error) { if !o.Has() { return nil, nil } value := new(yaml.Node) err := value.Encode(o.Value()) return value, err } ================================================ FILE: shared/optional/serialization_json_test.go ================================================ // Copyright 2025 Woodpecker Authors. // Copyright 2024 "6543". // // Licensed under the MIT License. package optional_test import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/shared/optional" ) func TestOptionalToJson(t *testing.T) { tests := []struct { name string obj *testSerializationStruct want string }{ { name: "empty", obj: new(testSerializationStruct), want: `{"normal_string":"","normal_bool":false,"optional_two_bool":null,"optional_twostring":null}`, }, { name: "some", obj: &testSerializationStruct{ NormalString: "a string", NormalBool: true, OptBool: optional.Some(false), OptString: optional.Some(""), }, want: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_twostring":null}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { b, err := json.Marshal(tc.obj) assert.NoError(t, err) assert.EqualValues(t, tc.want, string(b), "std json module returned unexpected") }) } } func TestOptionalFromJson(t *testing.T) { tests := []struct { name string data string want testSerializationStruct }{ { name: "empty", data: `{}`, want: testSerializationStruct{ NormalString: "", OptBool: optional.None[bool](), }, }, { name: "some", data: `{"normal_string":"a string","normal_bool":true,"optional_bool":false,"optional_string":"","optional_two_bool":null,"optional_twostring":null}`, want: testSerializationStruct{ NormalString: "a string", NormalBool: true, OptBool: optional.Some(false), OptString: optional.Some(""), }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var obj testSerializationStruct err := json.Unmarshal([]byte(tc.data), &obj) assert.NoError(t, err) assert.EqualValues(t, tc.want, obj, "std json module returned unexpected") }) } } ================================================ FILE: shared/optional/serialization_test.go ================================================ // Copyright 2025 Woodpecker Authors. // Copyright 2024 "6543". // // Licensed under the MIT License. package optional_test import ( "go.woodpecker-ci.org/woodpecker/v3/shared/optional" ) type testSerializationStruct struct { NormalString string `json:"normal_string" yaml:"normal_string"` NormalBool bool `json:"normal_bool" yaml:"normal_bool"` OptBool optional.Option[bool] `json:"optional_bool,omitempty" yaml:"optional_bool,omitempty"` OptString optional.Option[string] `json:"optional_string,omitempty" yaml:"optional_string,omitempty"` OptTwoBool optional.Option[bool] `json:"optional_two_bool" yaml:"optional_two_bool"` OptTwoString optional.Option[string] `json:"optional_twostring" yaml:"optional_two_string"` } ================================================ FILE: shared/optional/serialization_yaml_test.go ================================================ // Copyright 2025 Woodpecker Authors. // Copyright 2024 "6543". // // Licensed under the MIT License. package optional_test import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "go.woodpecker-ci.org/woodpecker/v3/shared/optional" ) type testBoolStruct struct { OptBoolOmitEmpty1 optional.Option[bool] `json:"opt_bool_omit_empty_1,omitempty" yaml:"opt_bool_omit_empty_1,omitempty"` OptBoolOmitEmpty2 optional.Option[bool] `json:"opt_bool_omit_empty_2,omitempty" yaml:"opt_bool_omit_empty_2,omitempty"` OptBoolOmitEmpty3 optional.Option[bool] `json:"opt_bool_omit_empty_3,omitempty" yaml:"opt_bool_omit_empty_3,omitempty"` OptBool4 optional.Option[bool] `json:"opt_bool_4" yaml:"opt_bool_4"` OptBool5 optional.Option[bool] `json:"opt_bool_5" yaml:"opt_bool_5"` OptBool6 optional.Option[bool] `json:"opt_bool_6" yaml:"opt_bool_6"` } func TestOptionalBoolYaml(t *testing.T) { tYaml := ` opt_bool_omit_empty_1: false opt_bool_omit_empty_2: true opt_bool_4: false opt_bool_5: true ` tObj := new(testBoolStruct) t.Run("Unmarshal", func(t *testing.T) { err := yaml.Unmarshal([]byte(tYaml), tObj) require.NoError(t, err) assert.EqualValues(t, &testBoolStruct{ OptBoolOmitEmpty1: optional.Some(false), OptBoolOmitEmpty2: optional.Some(true), OptBoolOmitEmpty3: optional.None[bool](), OptBool4: optional.Some(false), OptBool5: optional.Some(true), OptBool6: optional.None[bool](), }, tObj) }) t.Run("Marshal", func(t *testing.T) { tBytes, err := yaml.Marshal(tObj) require.NoError(t, err) assert.EqualValues(t, `opt_bool_omit_empty_1: false opt_bool_omit_empty_2: true opt_bool_4: false opt_bool_5: true opt_bool_6: null `, string(tBytes)) }) } func TestOptionalToYaml(t *testing.T) { tests := []struct { name string obj *testSerializationStruct want string }{ { name: "empty", obj: new(testSerializationStruct), want: `normal_string: "" normal_bool: false optional_two_bool: null optional_two_string: null `, }, { name: "some", obj: &testSerializationStruct{ NormalString: "a string", NormalBool: true, OptBool: optional.Some(false), OptString: optional.Some(""), }, want: `normal_string: a string normal_bool: true optional_bool: false optional_string: "" optional_two_bool: null optional_two_string: null `, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { b, err := yaml.Marshal(tc.obj) assert.NoError(t, err) assert.EqualValues(t, tc.want, string(b), "yaml module returned unexpected") }) } } func TestOptionalFromYaml(t *testing.T) { tests := []struct { name string data string want testSerializationStruct }{ { name: "empty", data: ``, want: testSerializationStruct{}, }, { name: "empty but init", data: `normal_string: "" normal_bool: false optional_bool: optional_two_bool: optional_two_string: `, want: testSerializationStruct{}, }, { name: "some", data: ` normal_string: a string normal_bool: true optional_bool: false optional_string: "" optional_two_bool: null optional_twostring: null `, want: testSerializationStruct{ NormalString: "a string", NormalBool: true, OptBool: optional.Some(false), OptString: optional.Some(""), }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var obj testSerializationStruct err := yaml.Unmarshal([]byte(tc.data), &obj) assert.NoError(t, err) assert.EqualValues(t, tc.want, obj, "yaml module returned unexpected") }) } } ================================================ FILE: shared/token/token.go ================================================ // Copyright 2018 Drone.IO Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package token import ( "fmt" "net/http" "slices" "github.com/golang-jwt/jwt/v5" "github.com/rs/zerolog/log" ) type SecretFunc func(*Token) (string, error) type Type string const ( UserToken Type = "user" // user token (exp cli) SessToken Type = "sess" // session token (ui token requires csrf check) HookToken Type = "hook" // repo hook token CsrfToken Type = "csrf" AgentToken Type = "agent" OAuthStateToken Type = "oauth-state" ) // SignerAlgo id default algorithm used to sign JWT tokens. const SignerAlgo = "HS256" type Token struct { Type Type claims jwt.MapClaims } func Parse(allowedTypes []Type, raw string, fn SecretFunc) (*Token, error) { token := &Token{ claims: jwt.MapClaims{}, } parsed, err := jwt.Parse(raw, keyFunc(token, fn)) if err != nil { return nil, err } if !parsed.Valid { return nil, jwt.ErrTokenUnverifiable } hasAllowedType := slices.Contains(allowedTypes, token.Type) if !hasAllowedType { return nil, jwt.ErrInvalidType } return token, nil } func ParseRequest(allowedTypes []Type, r *http.Request, fn SecretFunc) (*Token, error) { // first we attempt to get the token from the // authorization header. token := r.Header.Get("Authorization") if len(token) != 0 { log.Trace().Msgf("token.ParseRequest: found token in header: %s", token) bearer := token if _, err := fmt.Sscanf(token, "Bearer %s", &bearer); err != nil { return nil, err } return Parse(allowedTypes, bearer, fn) } token = r.Header.Get("X-Gitlab-Token") if len(token) != 0 { return Parse(allowedTypes, token, fn) } // then we attempt to get the token from the // access_token url query parameter token = r.FormValue("access_token") if len(token) != 0 { return Parse(allowedTypes, token, fn) } // and finally we attempt to get the token from // the user session cookie cookie, err := r.Cookie("user_sess") if err != nil { return nil, err } return Parse(allowedTypes, cookie.Value, fn) } func CheckCsrf(r *http.Request, fn SecretFunc) error { // get and options requests are always // enabled, without CSRF checks. switch r.Method { case http.MethodGet, http.MethodOptions: return nil } // parse the raw CSRF token value and validate raw := r.Header.Get("X-CSRF-TOKEN") _, err := Parse([]Type{CsrfToken}, raw, fn) return err } func New(tokenType Type) *Token { return &Token{Type: tokenType, claims: jwt.MapClaims{}} } // Sign signs the token using the given secret hash // and returns the string value. func (t *Token) Sign(secret string) (string, error) { return t.SignExpires(secret, 0) } // Sign signs the token using the given secret hash // with an expiration date. func (t *Token) SignExpires(secret string, exp int64) (string, error) { token := jwt.New(jwt.SigningMethodHS256) claims, ok := token.Claims.(jwt.MapClaims) if !ok { return "", fmt.Errorf("token claim is not a MapClaims") } for k, v := range t.claims { claims[k] = v } claims["type"] = t.Type if exp > 0 { claims["exp"] = float64(exp) } return token.SignedString([]byte(secret)) } func (t *Token) Set(key, value string) { t.claims[key] = value } func (t *Token) Get(key string) string { claim, ok := t.claims[key].(string) if !ok { return "" } return claim } func keyFunc(token *Token, fn SecretFunc) jwt.Keyfunc { return func(t *jwt.Token) (any, error) { claims, ok := t.Claims.(jwt.MapClaims) if !ok { return nil, fmt.Errorf("token claim is not a MapClaims") } // validate the correct algorithm is being used if t.Method.Alg() != SignerAlgo { return nil, jwt.ErrSignatureInvalid } // extract the token type and cast to the expected type tokenType, ok := claims["type"].(string) if !ok { return nil, jwt.ErrInvalidType } token.Type = Type(tokenType) // copy custom claims for k, v := range claims { // skip the reserved claims https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 if k == "iss" || k == "sub" || k == "aud" || k == "exp" || k == "nbf" || k == "iat" || k == "jti" { continue } if k == "type" { continue } token.claims[k] = v } // invoke the callback function to retrieve // the secret key used to verify secret, err := fn(token) return []byte(secret), err } } ================================================ FILE: shared/token/token_test.go ================================================ // Copyright 2021 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package token_test import ( "testing" "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "go.woodpecker-ci.org/woodpecker/v3/shared/token" ) const jwtSecret = "secret-to-sign-the-token" func TestTokenValid(t *testing.T) { _token := token.New(token.UserToken) _token.Set("user-id", "1") signedToken, err := _token.Sign(jwtSecret) assert.NoError(t, err) parsed, err := token.Parse([]token.Type{token.UserToken}, signedToken, func(_ *token.Token) (string, error) { return jwtSecret, nil }) assert.NoError(t, err) assert.NotNil(t, parsed) assert.Equal(t, "1", parsed.Get("user-id")) } func TestTokenWrongType(t *testing.T) { _token := token.New(token.UserToken) _token.Set("user-id", "1") signedToken, err := _token.Sign(jwtSecret) assert.NoError(t, err) _, err = token.Parse([]token.Type{token.AgentToken}, signedToken, func(_ *token.Token) (string, error) { return jwtSecret, nil }) assert.ErrorIs(t, err, jwt.ErrInvalidType) } func TestTokenWrongSecret(t *testing.T) { _token := token.New(token.UserToken) _token.Set("user-id", "1") signedToken, err := _token.Sign(jwtSecret) assert.NoError(t, err) _, err = token.Parse([]token.Type{token.UserToken}, signedToken, func(_ *token.Token) (string, error) { return "this-is-a-wrong-secret", nil }) assert.ErrorIs(t, err, jwt.ErrSignatureInvalid) } ================================================ FILE: shared/utils/context.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "context" "fmt" "os" "os/signal" "syscall" ) // Returns a copy of parent context that is canceled when // an os interrupt signal is received. func WithContextSigtermCallback(ctx context.Context, f func()) context.Context { ctx, cancel := context.WithCancelCause(ctx) go func() { receivedSignal := make(chan os.Signal, 1) signal.Notify(receivedSignal, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(receivedSignal) select { case <-ctx.Done(): case <-receivedSignal: if f != nil { f() } cancel(fmt.Errorf("received signal: %v", receivedSignal)) } }() return ctx } ================================================ FILE: shared/utils/paginate.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils // Paginate iterates over a func call until it does not return new items and return it as list. func Paginate[T any](get func(page int) ([]T, error), limit int) ([]T, error) { items := make([]T, 0, 10) page := 1 lenFirstBatch := -1 for { // limit < 1 means get all results remaining := 0 if limit > 0 { remaining = limit - len(items) if remaining <= 0 { break } } batch, err := get(page) if err != nil { return nil, err } // Take only what we need from this batch if limit > 0 if limit > 0 && len(batch) > remaining { batch = batch[:remaining] } items = append(items, batch...) if page == 1 { if len(batch) == 0 { return items, nil } lenFirstBatch = len(batch) } else if len(batch) < lenFirstBatch || len(batch) == 0 { break } page++ } return items, nil } ================================================ FILE: shared/utils/paginate_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "testing" "github.com/stretchr/testify/assert" ) func TestPaginate(t *testing.T) { // Generic mock generator that can handle all cases createMock := func(pages [][]int) func(page int) []int { return func(page int) []int { if page <= 0 { page = 0 } else { page-- } if page >= len(pages) { return []int{} } return pages[page] } } tests := []struct { name string limit int pages [][]int expected []int apiCalls int }{ { name: "multiple pages", limit: -1, pages: [][]int{{11, 12, 13}, {21, 22, 23}, {31, 32}}, expected: []int{11, 12, 13, 21, 22, 23, 31, 32}, apiCalls: 3, }, { name: "zero limit", limit: 0, pages: [][]int{{1, 2, 3}, {1, 2, 3}, {1, 2, 3}}, expected: []int{1, 2, 3, 1, 2, 3, 1, 2, 3}, apiCalls: 4, }, { name: "empty result", limit: 5, pages: [][]int{{}}, expected: []int{}, apiCalls: 1, }, { name: "limit less than batch", limit: 2, pages: [][]int{{1, 2, 3, 4, 5}}, expected: []int{1, 2}, apiCalls: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { apiExec := 0 mock := createMock(tt.pages) result, _ := Paginate(func(page int) ([]int, error) { apiExec++ return mock(page), nil }, tt.limit) assert.EqualValues(t, tt.apiCalls, apiExec) assert.EqualValues(t, tt.expected, result) }) } } ================================================ FILE: shared/utils/protected.go ================================================ // Copyright 2026 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "sync" ) // Protected provides thread-safe read and write access to a value of type T. type Protected[T any] interface { // Get returns the current value using a read lock, allowing multiple concurrent // readers. Safe to call from multiple goroutines simultaneously. Get() T // Set replaces the current value using an exclusive write lock. // Blocks until all ongoing reads/writes complete. Set(v T) // Update performs an atomic read-modify-write operation under a single exclusive // lock. The provided function receives the current value and returns the new value, // eliminating the race condition that would occur with a separate Get + Set. Update(fn func(T) T) } type protected[T any] struct { mu sync.RWMutex value T } // NewProtected creates and returns a new Protected wrapper initialized with the // given value. Use this as the constructor instead of creating a protected struct directly. func NewProtected[T any](initial T) Protected[T] { return &protected[T]{value: initial} } func (p *protected[T]) Get() T { p.mu.RLock() defer p.mu.RUnlock() return p.value } func (p *protected[T]) Set(v T) { p.mu.Lock() defer p.mu.Unlock() p.value = v } func (p *protected[T]) Update(fn func(T) T) { p.mu.Lock() defer p.mu.Unlock() p.value = fn(p.value) } ================================================ FILE: shared/utils/slices.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils // EqualSliceValues compare two slices if they have equal values independent of how they are sorted. func EqualSliceValues[E comparable](s1, s2 []E) bool { if len(s1) != len(s2) { return false } m1 := sliceToCountMap(s1) m2 := sliceToCountMap(s2) for k, v := range m1 { if m2[k] != v { return false } } return true } func sliceToCountMap[E comparable](list []E) map[E]int { m := make(map[E]int) for i := range list { m[list[i]]++ } return m } // SliceToBoolMap is a helper function to convert a string slice to a map. func SliceToBoolMap(s []string) map[string]bool { v := map[string]bool{} for _, ss := range s { if ss == "" { continue } v[ss] = true } return v } // StringSliceDeleteEmpty removes empty strings from a string slice. func StringSliceDeleteEmpty(s []string) []string { r := make([]string, 0) for _, str := range s { if str != "" { r = append(r, str) } } return r } ================================================ FILE: shared/utils/slices_test.go ================================================ // Copyright 2023 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "testing" "github.com/stretchr/testify/assert" ) func TestEqualSliceValues(t *testing.T) { tests := []struct { in1 []string in2 []string out bool }{{ in1: []string{"", "ab", "12", "ab"}, in2: []string{"12", "ab"}, out: false, }, { in1: nil, in2: nil, out: true, }, { in1: []string{"AA", "AA", "2", " "}, in2: []string{"2", "AA", " ", "AA"}, out: true, }, { in1: []string{"AA", "AA", "2", " "}, in2: []string{"2", "2", " ", "AA"}, out: false, }} for _, tc := range tests { assert.EqualValues(t, tc.out, EqualSliceValues(tc.in1, tc.in2), "could not correctly process input: '%#v', %#v", tc.in1, tc.in2) } assert.True(t, EqualSliceValues([]bool{true, false, false}, []bool{false, false, true})) assert.False(t, EqualSliceValues([]bool{true, false, false}, []bool{true, false, true})) } func TestSliceToBoolMap(t *testing.T) { assert.Equal(t, map[string]bool{ "a": true, "b": true, "c": true, }, SliceToBoolMap([]string{"a", "b", "c"})) assert.Equal(t, map[string]bool{}, SliceToBoolMap([]string{})) assert.Equal(t, map[string]bool{}, SliceToBoolMap([]string{""})) } func TestStringSliceDeleteEmpty(t *testing.T) { tests := []struct { in []string out []string }{{ in: []string{"", "ab", "ab"}, out: []string{"ab", "ab"}, }, { in: []string{"", "ab", ""}, out: []string{"ab"}, }, { in: []string{""}, out: []string{}, }} for _, tc := range tests { exp := StringSliceDeleteEmpty(tc.in) assert.EqualValues(t, tc.out, exp, "got '%#v', expects %#v", exp, tc.out) } } ================================================ FILE: shared/utils/strings.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils // DeduplicateStrings deduplicate string list, empty items are dropped. func DeduplicateStrings(src []string) []string { m := make(map[string]struct{}, len(src)) dst := make([]string, 0, len(src)) for _, v := range src { // Skip empty items if len(v) == 0 { continue } // Skip duplicates if _, ok := m[v]; ok { continue } m[v] = struct{}{} dst = append(dst, v) } return dst } ================================================ FILE: shared/utils/strings_test.go ================================================ // Copyright 2022 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package utils import ( "sort" "testing" "github.com/stretchr/testify/assert" ) func TestDeduplicateStrings(t *testing.T) { tests := []struct { in []string out []string }{{ in: []string{"", "ab", "12", "ab"}, out: []string{"12", "ab"}, }, { in: nil, out: nil, }, { in: []string{""}, out: nil, }} for _, tc := range tests { result := DeduplicateStrings(tc.in) sort.Strings(result) if len(tc.out) == 0 { assert.Len(t, result, 0) } else { assert.EqualValues(t, tc.out, result, "could not correctly process input '%#v'", tc.in) } } } ================================================ FILE: tools/tools.go ================================================ // Copyright 2024 Woodpecker Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build tools // +build tools package main import ( _ "github.com/getkin/kin-openapi/cmd/validate" _ "github.com/swaggo/swag/cmd/swag" ) ================================================ FILE: version/version.go ================================================ // Copyright 2019 Laszlo Fogas // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // cSpell:ignore ldflags package version // Version of Woodpecker, set with ldflags, from Git tag. var Version string // String returns the Version set at build time or "dev". func String() string { if Version == "" { return "dev" } return Version } ================================================ FILE: web/.gitignore ================================================ node_modules .DS_Store dist dist-ssr *.local ================================================ FILE: web/.prettierignore ================================================ .pnpm-store/ pnpm-lock.yaml dist coverage/ LICENSE components.d.ts src/assets/locales/*.json !src/assets/locales/en.json ================================================ FILE: web/.prettierrc.js ================================================ import { readFile } from 'node:fs/promises'; // eslint-disable-next-line antfu/no-top-level-await const config = JSON.parse(await readFile(new URL('../.prettierrc.json', import.meta.url))); export default { ...config, plugins: ['@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'], importOrder: [ '', // Imports not matched by other special words or groups. '', // Empty string will match any import not matched by other special words or groups. '^(#|@|~|\\$)(/.*)$', '', '^[./]', ], }; ================================================ FILE: web/.yamlignore ================================================ .pnpm-lock.yaml ================================================ FILE: web/LICENSE ================================================ Copyright 2017 Drone.IO Inc Copyright 2019 Laszlo Fogas Copyright 2020 Woodpecker Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --- Woodpecker icon by Georgiana Ionescu from the Noun Project Licensed as Creative Commons CC BY https://thenounproject.com/term/woodpecker/1761314/ ================================================ FILE: web/components.d.ts ================================================ /* eslint-disable */ /* prettier-ignore */ // @ts-nocheck // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 export {} declare module 'vue' { export interface GlobalComponents { ActionsTab: typeof import('./src/components/repo/settings/ActionsTab.vue')['default'] ActivePipelines: typeof import('./src/components/layout/header/ActivePipelines.vue')['default'] AdminAgentsTab: typeof import('./src/components/admin/settings/AdminAgentsTab.vue')['default'] AdminInfoTab: typeof import('./src/components/admin/settings/AdminInfoTab.vue')['default'] AdminOrgsTab: typeof import('./src/components/admin/settings/AdminOrgsTab.vue')['default'] AdminQueueStats: typeof import('./src/components/admin/settings/queue/AdminQueueStats.vue')['default'] AdminQueueTab: typeof import('./src/components/admin/settings/AdminQueueTab.vue')['default'] AdminRegistriesTab: typeof import('./src/components/admin/settings/AdminRegistriesTab.vue')['default'] AdminReposTab: typeof import('./src/components/admin/settings/AdminReposTab.vue')['default'] AdminSecretsTab: typeof import('./src/components/admin/settings/AdminSecretsTab.vue')['default'] AdminUsersTab: typeof import('./src/components/admin/settings/AdminUsersTab.vue')['default'] Badge: typeof import('./src/components/atomic/Badge.vue')['default'] BadgeTab: typeof import('./src/components/repo/settings/BadgeTab.vue')['default'] Button: typeof import('./src/components/atomic/Button.vue')['default'] Checkbox: typeof import('./src/components/form/Checkbox.vue')['default'] CheckboxesField: typeof import('./src/components/form/CheckboxesField.vue')['default'] CodeBox: typeof import('./src/components/layout/CodeBox.vue')['default'] Container: typeof import('./src/components/layout/Container.vue')['default'] CronTab: typeof import('./src/components/repo/settings/CronTab.vue')['default'] DeployPipelinePopup: typeof import('./src/components/layout/popups/DeployPipelinePopup.vue')['default'] DocsLink: typeof import('./src/components/atomic/DocsLink.vue')['default'] Error: typeof import('./src/components/atomic/Error.vue')['default'] ExtensionsTab: typeof import('./src/components/repo/settings/ExtensionsTab.vue')['default'] GeneralTab: typeof import('./src/components/repo/settings/GeneralTab.vue')['default'] Header: typeof import('./src/components/layout/scaffold/Header.vue')['default'] IBiCheckCircleFill: typeof import('~icons/bi/check-circle-fill')['default'] IBiExclamationTriangle: typeof import('~icons/bi/exclamation-triangle')['default'] IBiExclamationTriangleFill: typeof import('~icons/bi/exclamation-triangle-fill')['default'] IBiSlashCircleFill: typeof import('~icons/bi/slash-circle-fill')['default'] IBxBxPowerOff: typeof import('~icons/bx/bx-power-off')['default'] ICarbonCloseOutline: typeof import('~icons/carbon/close-outline')['default'] IClarityDeployLine: typeof import('~icons/clarity/deploy-line')['default'] IClaritySettingsSolid: typeof import('~icons/clarity/settings-solid')['default'] Icon: typeof import('./src/components/atomic/Icon.vue')['default'] IconButton: typeof import('./src/components/atomic/IconButton.vue')['default'] IGgTrash: typeof import('~icons/gg/trash')['default'] IIcBaselineDarkMode: typeof import('~icons/ic/baseline-dark-mode')['default'] IIcBaselineDownloadForOffline: typeof import('~icons/ic/baseline-download-for-offline')['default'] IIcBaselineEdit: typeof import('~icons/ic/baseline-edit')['default'] IIcBaselineFileDownload: typeof import('~icons/ic/baseline-file-download')['default'] IIcBaselineFileDownloadOff: typeof import('~icons/ic/baseline-file-download-off')['default'] IIcBaselineHealing: typeof import('~icons/ic/baseline-healing')['default'] IIcBaselinePause: typeof import('~icons/ic/baseline-pause')['default'] IIcBaselinePlayArrow: typeof import('~icons/ic/baseline-play-arrow')['default'] IIconoirArrowLeft: typeof import('~icons/iconoir/arrow-left')['default'] IIconParkOutlineAlarmClock: typeof import('~icons/icon-park-outline/alarm-clock')['default'] IIcRoundLightMode: typeof import('~icons/ic/round-light-mode')['default'] IIcSharpTimelapse: typeof import('~icons/ic/sharp-timelapse')['default'] IIcTwotoneAdd: typeof import('~icons/ic/twotone-add')['default'] ILaTimes: typeof import('~icons/la/times')['default'] IMdiBitbucket: typeof import('~icons/mdi/bitbucket')['default'] IMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] IMdiClockTimeEightOutline: typeof import('~icons/mdi/clock-time-eight-outline')['default'] IMdiCloseThick: typeof import('~icons/mdi/close-thick')['default'] IMdiErrorOutline: typeof import('~icons/mdi/error-outline')['default'] IMdiFormatListBulleted: typeof import('~icons/mdi/format-list-bulleted')['default'] IMdiGestureTap: typeof import('~icons/mdi/gesture-tap')['default'] IMdiGithub: typeof import('~icons/mdi/github')['default'] IMdiLoading: typeof import('~icons/mdi/loading')['default'] IMdiPlay: typeof import('~icons/mdi/play')['default'] IMdiRadioboxBlank: typeof import('~icons/mdi/radiobox-blank')['default'] IMdiRadioboxIndeterminateVariant: typeof import('~icons/mdi/radiobox-indeterminate-variant')['default'] IMdiSourceBranch: typeof import('~icons/mdi/source-branch')['default'] IMdiSourceCommit: typeof import('~icons/mdi/source-commit')['default'] IMdiSourceMerge: typeof import('~icons/mdi/source-merge')['default'] IMdiSourcePull: typeof import('~icons/mdi/source-pull')['default'] IMdiStop: typeof import('~icons/mdi/stop')['default'] IMdiSync: typeof import('~icons/mdi/sync')['default'] IMdiTagOutline: typeof import('~icons/mdi/tag-outline')['default'] InputField: typeof import('./src/components/form/InputField.vue')['default'] IPhGitlabLogoSimpleFill: typeof import('~icons/ph/gitlab-logo-simple-fill')['default'] ISimpleIconsForgejo: typeof import('~icons/simple-icons/forgejo')['default'] ISimpleIconsGitea: typeof import('~icons/simple-icons/gitea')['default'] ISvgSpinners180RingWithBg: typeof import('~icons/svg-spinners/180-ring-with-bg')['default'] ITeenyiconsGitSolid: typeof import('~icons/teenyicons/git-solid')['default'] ITeenyiconsRefreshOutline: typeof import('~icons/teenyicons/refresh-outline')['default'] IVaadinQuestionCircleO: typeof import('~icons/vaadin/question-circle-o')['default'] ListItem: typeof import('./src/components/atomic/ListItem.vue')['default'] ManualPipelinePopup: typeof import('./src/components/layout/popups/ManualPipelinePopup.vue')['default'] Navbar: typeof import('./src/components/layout/header/Navbar.vue')['default'] NumberField: typeof import('./src/components/form/NumberField.vue')['default'] OrgRegistriesTab: typeof import('./src/components/org/settings/OrgRegistriesTab.vue')['default'] OrgSecretsTab: typeof import('./src/components/org/settings/OrgSecretsTab.vue')['default'] Panel: typeof import('./src/components/layout/Panel.vue')['default'] PipelineFeedItem: typeof import('./src/components/pipeline-feed/PipelineFeedItem.vue')['default'] PipelineFeedSidebar: typeof import('./src/components/pipeline-feed/PipelineFeedSidebar.vue')['default'] PipelineItem: typeof import('./src/components/repo/pipeline/PipelineItem.vue')['default'] PipelineList: typeof import('./src/components/repo/pipeline/PipelineList.vue')['default'] PipelineLog: typeof import('./src/components/repo/pipeline/PipelineLog.vue')['default'] PipelineRunningIcon: typeof import('./src/components/repo/pipeline/PipelineRunningIcon.vue')['default'] PipelineStatusIcon: typeof import('./src/components/repo/pipeline/PipelineStatusIcon.vue')['default'] PipelineStepDuration: typeof import('./src/components/repo/pipeline/PipelineStepDuration.vue')['default'] PipelineStepList: typeof import('./src/components/repo/pipeline/PipelineStepList.vue')['default'] Popup: typeof import('./src/components/layout/Popup.vue')['default'] RadioField: typeof import('./src/components/form/RadioField.vue')['default'] RegistriesTab: typeof import('./src/components/repo/settings/RegistriesTab.vue')['default'] RegistryEdit: typeof import('./src/components/registry/RegistryEdit.vue')['default'] RegistryList: typeof import('./src/components/registry/RegistryList.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] Scaffold: typeof import('./src/components/layout/scaffold/Scaffold.vue')['default'] SecretEdit: typeof import('./src/components/secrets/SecretEdit.vue')['default'] SecretList: typeof import('./src/components/secrets/SecretList.vue')['default'] SecretsTab: typeof import('./src/components/repo/settings/SecretsTab.vue')['default'] SelectField: typeof import('./src/components/form/SelectField.vue')['default'] Settings: typeof import('./src/components/layout/Settings.vue')['default'] Tab: typeof import('./src/components/layout/scaffold/Tab.vue')['default'] Tabs: typeof import('./src/components/layout/scaffold/Tabs.vue')['default'] TextField: typeof import('./src/components/form/TextField.vue')['default'] UserCLIAndAPITab: typeof import('./src/components/user/UserCLIAndAPITab.vue')['default'] UserGeneralTab: typeof import('./src/components/user/UserGeneralTab.vue')['default'] UserRegistriesTab: typeof import('./src/components/user/UserRegistriesTab.vue')['default'] UserSecretsTab: typeof import('./src/components/user/UserSecretsTab.vue')['default'] Warning: typeof import('./src/components/atomic/Warning.vue')['default'] } } ================================================ FILE: web/eslint.config.js ================================================ // cSpell:ignore tseslint // @ts-check import antfu from '@antfu/eslint-config'; import js from '@eslint/js'; import vueI18n from '@intlify/eslint-plugin-vue-i18n'; import eslintPromise from 'eslint-plugin-promise'; import eslintPluginVueScopedCSS from 'eslint-plugin-vue-scoped-css'; export default antfu( { stylistic: false, typescript: { tsconfigPath: './tsconfig.json', }, vue: true, // Disable jsonc and yaml support jsonc: false, yaml: false, }, js.configs.recommended, eslintPromise.configs['flat/recommended'], ...eslintPluginVueScopedCSS.configs['flat/recommended'], ...vueI18n.configs['flat/recommended'], { rules: { 'import/order': 'off', 'sort-imports': 'off', 'perfectionist/sort-imports': 'off', 'perfectionist/sort-named-imports': 'off', 'promise/prefer-await-to-callbacks': 'error', 'vue-scoped-css/no-parsing-error': 'off', // Vue I18n '@intlify/vue-i18n/no-raw-text': [ 'error', { attributes: { '/.+/': ['label'], }, }, ], '@intlify/vue-i18n/key-format-style': ['error', 'snake_case'], '@intlify/vue-i18n/no-duplicate-keys-in-locale': 'error', '@intlify/vue-i18n/no-dynamic-keys': 'error', '@intlify/vue-i18n/no-deprecated-i18n-component': 'error', '@intlify/vue-i18n/no-deprecated-tc': 'error', '@intlify/vue-i18n/no-i18n-t-path-prop': 'error', '@intlify/vue-i18n/no-missing-keys-in-other-locales': 'off', '@intlify/vue-i18n/valid-message-syntax': 'error', '@intlify/vue-i18n/no-missing-keys': 'error', '@intlify/vue-i18n/no-unknown-locale': 'error', '@intlify/vue-i18n/no-unused-keys': ['error', { extensions: ['.ts', '.vue'] }], '@intlify/vue-i18n/prefer-sfc-lang-attr': 'error', '@intlify/vue-i18n/no-html-messages': 'error', '@intlify/vue-i18n/prefer-linked-key-with-paren': 'error', '@intlify/vue-i18n/sfc-locale-attr': 'error', }, settings: { // Vue I18n 'vue-i18n': { localeDir: './src/assets/locales/en.json', // Specify the version of `vue-i18n` you are using. // If not specified, the message will be parsed twice. messageSyntaxVersion: '^9.0.0', }, }, }, // Vue { files: ['**/*.vue'], rules: { 'vue/multi-word-component-names': 'off', 'vue/html-self-closing': [ 'error', { html: { void: 'always', normal: 'always', component: 'always', }, svg: 'always', math: 'always', }, ], 'vue/html-indent': 'off', 'vue/block-order': [ 'error', { order: ['template', 'script', 'style'], }, ], 'vue/singleline-html-element-content-newline': ['off'], 'no-useless-assignment': ['off'], }, }, // Ignore list { ignores: [ 'dist', 'coverage/', 'package.json', 'tsconfig.eslint.json', 'tsconfig.json', 'src/assets/locales/**/*', '!src/assets/locales/en.json', 'components.d.ts', ], }, ); ================================================ FILE: web/index.html ================================================ Woodpecker
================================================ FILE: web/package.json ================================================ { "name": "woodpecker-ci", "author": "Woodpecker CI", "version": "0.0.0", "license": "Apache-2.0", "packageManager": "pnpm@10.33.4", "type": "module", "engines": { "node": ">=20" }, "scripts": { "start": "vite", "build": "vite build --base=/BASE_PATH", "serve": "vite preview", "lint": "eslint --max-warnings 0 .", "format": "prettier --write .", "format:check": "prettier -c .", "typecheck": "vue-tsc --noEmit", "test": "vitest" }, "dependencies": { "@kyvg/vue3-notification": "^3.4.2", "@mdi/js": "^7.4.47", "@vueuse/core": "^14.2.1", "ansi_up": "^6.0.6", "dompurify": "^3.4.0", "fuse.js": "^7.3.0", "js-base64": "^3.7.8", "marked": "^18.0.0", "node-emoji": "^2.2.0", "pinia": "^3.0.4", "prismjs": "^1.30.0", "semver": "^7.7.4", "simple-icons": "^16.16.0", "tailwindcss": "^4.2.2", "vue": "^3.5.32", "vue-i18n": "^11.3.2", "vue-router": "^5.0.4" }, "devDependencies": { "@antfu/eslint-config": "^8.2.0", "@eslint/js": "^10.0.1", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@intlify/eslint-plugin-vue-i18n": "4.3.0", "@intlify/unplugin-vue-i18n": "^11.0.7", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "4.3.0", "@types/node": "^24.12.2", "@types/prismjs": "^1.26.6", "@types/semver": "^7.7.1", "@types/tinycolor2": "^1.4.6", "@vitejs/plugin-vue": "^6.0.6", "@vue/compiler-sfc": "^3.5.32", "@vue/test-utils": "^2.4.6", "dotenv": "^17.4.2", "eslint": "^10.2.0", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-vue-scoped-css": "^3.0.0", "jsdom": "^29.0.2", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", "tinycolor2": "^1.6.0", "ts-node": "^10.9.2", "typescript": "6.0.3", "vite": "^8.0.5", "vite-plugin-prismjs": "^0.0.11", "vite-svg-loader": "^5.1.1", "vitest": "^4.1.4", "vue-tsc": "^3.2.6" }, "pnpm": { "overrides": { "semver@<7.5.2": ">=7.5.2" } } } ================================================ FILE: web/src/App.vue ================================================ ================================================ FILE: web/src/assets/locales/bar.json ================================================ { "repo": { "deploy_pipeline": { "trigger": "Ausrolln", "variables": { "delete": "Variable löschn", "title": "Zusätzliche Pipeline-Variabln", "desc": "Gib extra Variabln o de wo in deina Pipeline verwendt wern. Variabln mit'm gleichn Nama wern überschriebn.", "name": "Variabl-Nama", "value": "Variabl-Wert" }, "title": "Deployment füa aktuelle Pipeline #{pipelineId} lostreten", "enter_target": "Zielumgebung füas Deployment", "enter_task": "Deployment-Aufgab" }, "enable": { "success": "Repo is jez o", "enable": "Oschoidn", "enabled": "Is scho o", "disabled": "Is aus", "new_forge_repo": "Neis Repo auf da Forge", "stale_wp_repo": "Oids Woodpecker Repo", "conflict": "Konflikt", "conflict_desc": "Des Repo is auf da Forge mid na nein ID erstäid woan, aba in Woodpecker is no a oids repo mim selbn Nama gspeichert. Entweda schmeist's oide weg und aktivierst's neue, oda du reparierst's alte.", "forge_repo_missing": "Forge repo is ned do!" }, "visibility": { "public": { "public": "Für olle", "desc": "Jeda ko dei Repo seng, a ohne dass er eigloggt is." }, "visibility": "Wer kos seng", "private": { "private": "Privat", "desc": "Nur du und de andern Besitzer vom Repo kennan des seng." }, "internal": { "internal": "Intern", "desc": "Nur eigloggte Leit vo da Woodpecker-Instanz kennan des seng." } }, "pipeline": { "pipelines_for": "Pipelines fia Branch \"{branch}\"", "pipelines_for_pr": "Pipelines fia Pull Request #{index}", "exit_code": "Exit-Code {exitCode}", "loading": "Lod grod…", "no_logs": "Koane Logs", "pipeline": "Pipeline #{pipelineId}", "log_title": "Schrid-Logs", "log_download_error": "Beim Runterladn vom Log-File is wos schiaf ganga", "protected": { "decline": "Oblehnen", "awaits": "De Pipeline dritschld auf Freigab von am Maintainer!", "approve": "Freigem", "declined": "De Pipeline wurad obglehnt!", "approve_success": "Pipeline freigem", "decline_success": "Pipeline obglehnt" }, "tasks": "Aufgabn", "config": "Einstellung", "files": "Gänderte Datein", "no_pipelines": "Do san no koane Pipelines gstartet worn.", "no_pipeline_steps": "Koane Pipeline-Schrid do!", "step_not_started": "Der Schrid hod no ned ogfangd.", "log_delete_confirm": "Wuisd du wirkli de Schrid-Logs löschn?", "log_delete_error": "'Da ganze Bus is hi!'", "actions": { "cancel": "Abbrechn", "restart": "Neistarten", "canceled": "Der Schrid wurad abbrocha.", "cancel_success": "Pipeline abbrocha", "deploy": "Ausrolln", "restart_success": "Pipeline neigstartet", "log_download": "Runterladn", "log_delete": "Löschn", "log_auto_scroll": "Automatisch scrollen oschoidn", "log_auto_scroll_off": "Automatisch scrollen ausschoidn", "skipped": "Da Schrid is ausglosn woan." }, "event": { "push": "Push", "tag": "Tag", "pr": "Pull Request", "pr_closed": "Pull Request g'merged/zuagmacht", "pr_metadata": "Pull Request-Metadaten gändert", "deploy": "Deploy", "cron": "Cron", "manual": "Vo Hand", "release": "Release" }, "status": { "status": "Status: {status}", "blocked": "blockiert", "pending": "dritschld", "running": "lafts", "started": "gstartet", "skipped": "übersprungen", "success": "hods gschafft", "declined": "obglehnt", "error": "Fella", "failure": "is schiaf ganga", "killed": "obgwürgt", "canceled": "abgwürgt" }, "errors": "Fella", "warnings": "Warnunga", "show_errors": "Fella zoang", "we_got_some_errors": "Oh na, do hamma aba sauba z diaf ins glas gschaud!", "duration": "Pipeline-Dauer: {duration}", "created": "Erstäid: {created}", "debug": { "title": "Debug", "download_metadata": "Metadaten runterladn", "metadata_download_error": "Fella beim Runterladn vo de Metadaten", "metadata_download_successful": "Metadaten erfoigreich runtergladen", "no_permission": "Du deafst de Debug-Infos ned oseng", "metadata_exec_title": "Pipeline lokal neistarten", "metadata_exec_desc": "Load de Metadaten vo dera Pipeline runter um se lokal laufa zu lassn. So kost Probleme fixen und Änderunga testn bevors'd sie commitest. De Woodpecker CLI muas lokal in da gleichn Version wia da Server sei." }, "view": "Pipeline oschaun", "load_more": "Zoag mea o", "cancel_info": { "superseded_by": "Ersetzt duach #{pipelineId}", "canceled_by_user": "Abgewürgt duach {user}", "canceled_by_step": "Abgwürgt wenga {step}" }, "version": "De Woodpecker Version auf dea die Pipeline ausgefüad wurde.", "version_header": "Woodpecker Version" }, "manual_pipeline": { "title": "Pipeline vo Hand starten", "trigger": "Pipeline loslassn", "select_branch": "Branch auswähln", "variables": { "delete": "Variable löschn", "title": "Zusätzliche Pipeline-Variabln", "desc": "Gib extra Variabln o de wo in deina Pipeline verwendt wern. Variabln mid'm gleichn Nama wern überschriebn.", "name": "Variabl-Nama", "value": "Variabl-Wert" }, "show_pipelines": "Pipelines zoang", "no_manual_workflows": "Koane basadn Workflows gfundn. Bas auf das zumindest oa Workflow auf des von hand lafa Ereignis head." }, "activity": "Ois da Reih nach", "branches": "Branches", "pull_requests": "Pull Requests", "add": "Repo dazua doa", "user_none": "De Organisation/da User hod no koane Projekte", "not_allowed": "Du deafst do ned nei", "open_in_forge": "Repo in da Forge aufmacha", "settings": { "not_allowed": "Du deafst de Eistellunga von dem Repo ned ändarn", "general": { "general": "Repo", "project": "Repo-Eistellunga", "save": "Eistellunga speichan", "success": "Repo-Eistellunga gändert", "pipeline_path": { "path": "Pipeline-Pfad", "default": "Standardmäßig: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Pfad zu deina Pipeline-Config (zum Beispui {0}). Ordner soin mit am {1} aufhörn.", "desc_path_example": "mei/pfad/" }, "allow_pr": { "allow": "Pull Requests erlaubn", "desc": "Pipelines bei Pull Requests erlaubn." }, "allow_deploy": { "allow": "Deployments erlaubn", "desc": "Deployments füa erfoigreiche Pipelines erlaubn. Olle Leit mit Push-Rechte kennan des auslösn, also aufpassn!" }, "netrc_only_trusted": { "netrc_only_trusted": "Vatrauenswürdige Clone-Plugins", "desc": "Plugins de wo Zuagriff auf netrc-Credentials kriagn zum Repos vo da Forge klonen oda eineschiabm." }, "trusted": { "trusted": "Vatrauenswürdig", "network": { "network": "Netzwerk", "desc": "Pipeline-Container kriagn Zuagriff auf Netzwerk-Privilegien wia DNS ändern." }, "volumes": { "volumes": "Volumes", "desc": "Pipeline-Container derfa Volumes mounten." }, "security": { "security": "Sicherheit", "desc": "Pipeline-Container kriagn Zuagriff auf Sicherheits-Privilegien." } }, "timeout": { "timeout": "Zeitlimit", "minutes": "Minutn" }, "cancel_prev": { "cancel": "Vorherige Pipelines bleim losn", "desc": "Ausgwäids Events brechan dritschlde oder los glaffane Pipelines vom gleichn Event und Quain o." } }, "crons": { "crons": "Crons", "desc": "Cron-Jobs kennan verwendt wern zum Pipelines regelmäßig los laffa losn.", "show": "Crons zoang", "add": "Cron dazua doa", "none": "Do gibts no koane Crons.", "save": "Cron speichan", "created": "Cron erstäid", "saved": "Cron gspeichert", "deleted": "Cron glöscht", "next_exec": "Nächste Ausführung", "not_executed_yet": "No nia gloffa", "run": "Jez starten", "branch": { "title": "Branch", "placeholder": "Branch (nimmt Standard-Branch wenns la is)" }, "name": { "name": "Nama", "placeholder": "Nama vom Cron-Job" }, "schedule": { "title": "Zeitplan (basiert auf UTC)", "placeholder": "Zeitplan" }, "edit": "Cron endan", "delete": "Cron löschn", "enabled": "Eigscheudn" }, "badge": { "badge": "Plakettl", "type": "Syntax", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "branch": "Branch", "events": "Events", "step": "Schrid", "workflow": "Workflow" }, "actions": { "actions": "Aktiona", "repair": { "repair": "Repo rebarieren", "success": "Repo rebariert" }, "disable": { "disable": "Repo abschoidn", "success": "Repo abgschoidn" }, "enable": { "enable": "Repo oschoidn", "success": "Repo ogschoidn" }, "delete": { "delete": "Repo löschn", "confirm": "Ois is weg noch da Aktion!\n\nWuisd du wirkli weida macha?", "success": "Repo glöscht" } } } }, "admin": { "settings": { "repos": { "repos": "Repos", "desc": "Repos de wo auf dem Server ogschoidn san oda warn.", "none": "Do gibts no koane Repos.", "view": "Repo oschaun", "settings": "Repo-Eistellunga", "disabled": "ab gschoidn", "repair": { "repair": "Olle rebarieren", "success": "Repos rebariert" } }, "agents": { "created": "Agent erstäid", "saved": "Agent gspeichert", "deleted": "Agent glöscht", "name": { "name": "Nama", "placeholder": "Nama vom Agent" }, "agents": "Agents", "desc": "Agents de wo auf dem Server registriert san.", "none": "Do gibts no koane Agents.", "id": "ID", "add": "Agent dazua doa", "save": "Agent speichan", "show": "Agents zoang", "no_schedule": { "name": "Agent abschoidn", "placeholder": "Agent griagt koane neien Aufgabn mehr" }, "token": "Token", "platform": { "platform": "Plattform", "badge": "plattform" }, "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "capacity": "Kapazität", "desc": "Maximale Anzoi vo gleichzeidige Pipelines de wo der Agent ausführt.", "badge": "kapazität" }, "custom_labels": { "custom_labels": "Eigene Labels", "desc": "De eigenen Labels de wo vom Agent-Admin beim Starten gsetzt worn san." }, "org": { "badge": "org" }, "version": "Version", "last_contact": { "last_contact": "z'letzt gesng", "badge": "z'letzt gesng" }, "never": "Nia", "delete_confirm": "Wuisd du wirkli den Agent löschn? Er ko dann nahad nimmer mid'm Server obandln.", "edit_agent": "Agent beabadn", "delete_agent": "Agent löschn" }, "settings": "Admin-Eistellunga", "not_allowed": "Du deafst de Server-Eistellunga ned ändarn", "secrets": { "desc": "Globale Secrets kennan in de Pipelines vo olle Repos verwendt wern.", "warning": "De Secrets san füa olle User verfügbar." }, "registries": { "desc": "Globale Registry-Credentials kennan dann dazuagfügt wern zum private Images füa olle Pipelines verwenden.", "warning": "De Registry-Credentials san füa olle User verfügbar." }, "queue": { "queue": "Warteschlong", "desc": "Aufgabn de wo drauf wartn dass de Agents se ausführn.", "pause": "Pausieren", "resume": "Weida macha", "paused": "Warteschlong is pausiert", "resumed": "Warteschlong laft wieda", "tasks": "Aufgabn", "task_running": "Aufgab lafts grod", "task_pending": "Aufgab dritschld", "task_waiting_on_deps": "Aufgab dritschld auf n Vorarbeida", "agent": "agent", "waiting_for": "dritschld auf", "stats": { "completed_count": "Fertige Aufgabn", "worker_count": "Frei", "running_count": "Am Laufa", "pending_count": "Dritschld", "waiting_on_deps_count": "Dritschld auf n Vorarbeida" } }, "users": { "users": "Benutzer", "desc": "Benutzer de wo auf dem Server registriert san.", "login": "Login", "email": "E-Mail", "avatar_url": "Avatar-URL", "save": "Benutzer speichan", "cancel": "Abbrechn", "show": "Benutzer zoang", "add": "Benutzer dazua doa", "none": "Do gibts no koane Benutzer.", "delete_confirm": "Wuisd du wirkli den Benutzer löschn? Des löscht a olle Repos de wo dem Benutzer ghörn.", "deleted": "Benutzer glöscht", "created": "Benutzer erstäid", "saved": "Benutzer gspeichert", "admin": { "admin": "Admin", "placeholder": "Benutzer is a Admin" }, "delete_user": "Benutzer löschn", "edit_user": "Benutzer beabadn" }, "orgs": { "desc": "Organisationen de wo Repos auf dem Server ham.", "none": "Do gibts no koane Organisationen.", "orgs": "Organisationen", "org_settings": "Organisations-Eistellunga", "delete_org": "Organisation löschn", "deleted": "Organisation glöscht", "delete_confirm": "Wuisd du wirkli de Organisation löschn? Des löscht a olle Repos de wo da Organisation ghörn.", "view": "Organisation oschaun" } } }, "secrets": { "name": "Nama", "secrets": "Secrets", "desc": "Secrets kennan in olle Pipelines vo dem Repo verwendt wern.", "none": "Do gibts no koane Secrets.", "add": "Secret dazua doa", "save": "Secret speichan", "show": "Secrets zoang", "value": "Wert", "delete_confirm": "Wuisd du wirkli des Secret löschn?", "deleted": "Secret glöscht", "created": "Secret erstäid", "saved": "Secret gspeichert", "plugins": { "images": "Nur verfügbar füa de folgenden Plugins", "desc": "Liste vo Plugin-Images wo des Secret verfügbar is. Leer lassn zum olle Plugins und normale Schrid erlaubn." }, "events": { "events": "Verfügbar bei de folgenden Events", "warning": "Aufpassn: Secrets bei Pull Requests freigem ko gfährlich sei, weil böswillige Leit mid am bösen Pull Request dei Secrets klaubn kennan." }, "edit": "Secret beabadn", "delete": "Secret löschn", "note": "Notizn" }, "info": "Info", "cli_login_failed": "CLI-Login is schiaf ganga", "cli_login_denied": "CLI-Login wurad obglehnt", "return_to_cli": "Du kost jez den Tab zua macha und zruck zur CLI gehn.", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Addon", "forge_type": "Forge-Typ", "oauth_client_id": "OAuth Client ID", "merge_ref_desc": "Ref zum füa de Merge-Basis verwenden. Des wird verwendt zum Diff füa Pull Requests bestimma.", "public_only": "Nur öffentliche", "public_only_desc": "Nur öffentliche Repos zoang.", "git_username": "Git-Benutzername", "repositories": { "title": "Repositorys", "all": { "title": "Olle Repositorys", "desc": "Repositorys sortiert noch da letzten Pipeline" }, "last": { "title": "Letztns ogschaut", "desc": "De wo'd zuletzt dro waren, sortiert noch da Zeit" } }, "cancel": "Los ma's bleim", "login_to_woodpecker_with": "Bei Woodpecker eilogga mid", "login": "Eilogga", "repos": "Repositorys", "docs": "Doku", "api": "API", "logout": "Auslogga", "search": "Suacha…", "username": "Benutzanama", "password": "Passwort", "back": "Zruck", "unknown_error": "Irgendwos is schiaf ganga", "documentation_for": "Doku füa \"{topic}\"", "pipeline_feed": "Pipeline-Gschichtn", "empty_list": "Koane {entity} gfundn!", "not_found": { "not_found": "Oha, 404! Entweder ham mas vazapft oda du host di vatippt :-/", "back_home": "Zruck zur Hauptseitn" }, "errors": { "not_found": "Server findt des ned wos'd wuisd" }, "time": { "not_started": "hod no ned ogfangd", "just_now": "grod eben" }, "org": { "settings": { "not_allowed": "Du deafst de Eistellunga vo dera Organisation ned ändarn", "secrets": { "desc": "Organisations-Secrets kennan in de Pipelines vo olle Repos vo da Organisation verwendt wern." }, "registries": { "desc": "Organisations-Registry-Credentials kennan dazuagfügt wern zum private Images fia olle Pipelines vo ana Organisation verwenden." }, "agents": { "desc": "Agents de wo fia de Organisation registriert san." } } }, "user": { "settings": { "settings": "Benutzer-Eistellunga", "general": { "general": "Konto", "language": "Sproch", "theme": { "theme": "Design", "light": "Hell", "dark": "Dunkel", "auto": "Automatisch" } }, "secrets": { "desc": "User-Secrets kennan in de Pipelines vo olle Repos vom User verwendt wern." }, "registries": { "desc": "User-Registry-Credentials kennan dann dazuagfügt wern zum private Images füa olle persönlichen Pipelines verwenden." }, "cli_and_api": { "cli_and_api": "CLI & API", "desc": "Persönlicher Access-Token, CLI und API-Verwendung", "token": "Persönlicher Access-Token", "api_usage": "Beispui API-Verwendung", "cli_usage": "Beispui CLI-Verwendung", "download_cli": "CLI runterladn", "reset_token": "Token zrucksetzen", "swagger_ui": "Swagger UI" }, "agents": { "desc": "Agents de wo füa dei Account-Repos registriert san." } } }, "registries": { "registries": "Registries", "credentials": "Registry-Credentials", "desc": "Registry-Credentials kennan dann dazuagfügt wern zum private Images füa Pipelines verwenden.", "none": "Do gibts no koane Registry-Credentials.", "address": { "address": "Adress", "desc": "Registry-Adress (z.B. docker.io)" }, "show": "Registries zoang", "save": "Registry speichan", "add": "Registry dazua doa", "view": "Registry oschaun", "edit": "Registry beabadn", "delete": "Registry löschn", "delete_confirm": "Wuisd du wirkli de Registry löschn?", "created": "Registry-Credentials erstäid", "saved": "Registry-Credentials gspeichert", "deleted": "Registry-Credentials glöscht" }, "default": "standard", "running_version": "Du host Woodpecker {0} am Laufa", "update_woodpecker": "Bitte update dei Woodpecker-Instanz auf {0}", "global_level_secret": "globales Secret", "org_level_secret": "Organisations-Secret", "login_to_cli": "Bei da CLI eilogga", "login_to_cli_description": "Wennd weitermachst, wirst bei da CLI eigloggt.", "abort": "Abbrechn", "cli_login_success": "CLI-Login hot klappt", "settings": "Eistellunga", "oauth_error": "Fella bei da OAuth-Authentifizierung", "internal_error": "Interner Fella is passiert", "registration_closed": "De Registrierung is zua", "access_denied": "Du deafst auf de Instanz ned zuagreifen", "org_access_denied": "Du deafst auf de Organisation ned zuagreifen", "invalid_state": "Da OAuth-State is ungültig", "extensions": "Erweiterunga", "extensions_description": "Erweiterunga san HTTP-Services de wo von Woodpecker aufgruafa wern kennan anstatt de mitglifadn zu nutzn.", "extension_endpoint_placeholder": "z.B. https://example.com/api", "config_extension_endpoint": "Config-Erweiterungs-Endpunkt", "extensions_signatures_public_key": "Öffentlicher Schlüssl fia Signaturen", "extensions_signatures_public_key_description": "Der öffentliche Schlüssl soi vo deine Erweiterunga verwendt wern zum Webhook-Aufruaf vo Woodpecker verifizieren.", "extensions_configuration_saved": "Erweiterunga-Konfiguration gspeichert", "require_approval": { "desc": "Vahindere dass böswillige Pipelines Secrets ausplaudern oda schädliche Sachen macha durch Freigab vor da Ausführung.", "require_approval_for": "Freigab-Anforderunga", "none": "Koane", "none_desc": "Jeds Event lösd'd Pipelines aus, a Pull Requests. De Eistellung ko gfährlich sei und is nur füa private Instanzen empfohln.", "forks": "Pull Request von am geforkten Repo", "pull_requests": "Olle Pull Requests", "all_events": "Olle Events vo da Forge", "allowed_users": { "allowed_users": "Erlaubte Benutzer", "desc": "Pipelines de wo vo de aufglisteten Benutzer erstäid worn san brauchan koan Pasierschein A38." } }, "no_search_results": "Nix gfundn", "forges": "Forges", "forges_desc": "Forges konfigurieren de wo Repos hostn füa de wo Woodpecker laufa soi.", "add_forge": "Forge dazua doa", "show_forges": "Forges zoang", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "oauth_client_secret": "OAuth Client Secret", "oauth_host": "OAuth Host", "merge_ref": "Merge Ref", "git_username_desc": "Benutzername fia'n Git-User.", "git_password": "Git-Passwort", "git_password_desc": "Passwort oda persönlicher Access-Token fia'n Git-User.", "executable": "Ausführbare Datei", "executable_desc": "Pfad zur Addon-Executable.", "save": "Speichan", "add": "Dazua doa", "skip_verify": "SSL-Überprüfung überspringen", "skip_verify_desc": "SSL-Überprüfung füa de API-Verbindung überspringen. Des is ned empfohln füan Produktiv-Einsatz.", "url": "URL", "forge_managed_by_env": "De primäre Forge wird über Umgebungsvariabln verwaltet. Olle Änderunga an dera Forge wern bei am Neustart zruckgsetzt.", "oauth_redirect_url": "OAuth Redirect-URL", "forge_created": "Forge erstäid", "advanced_options": "Erweiterte Optionen", "leave_empty_to_keep_current_value": "Leer lassn zum aktuellen Wert behalten", "forge_deleted": "Forge glöscht", "forge_delete_confirm": "Wuisd du wirkli de Forge löschn? Des löscht a olle Repos, User und Pipelines de wo zu dera Forge ghörn.", "edit_forge": "Forge beabadn", "delete_forge": "Forge löschn", "no_forges": "Do gibts no koane Forges.", "use_this_redirect_url_to_create": "Verwend de Redirect-URL zum de OAuth-Anwendung erstellen oda aktualisieren.", "developer_settings_to_create": "Geh zu de {0} und richt de OAuth-Anwendung ei.", "developer_settings": "Developer-Eistellunga", "public_url_for_oauth_if": "Öffentliche URL füa OAuth falls anders ois URL ({0})", "forge_saved": "Forge gspeichert", "fullscreen": "Vollbild", "exit_fullscreen": "Vollbild verlassn", "help_translating": "Du kost helfa Woodpecker in dei Sproch zu übersetzen auf {0}.", "weblate": "unserm Weblate", "disabled": "Abgschoidn", "config_extension_exclusive": "Ganz Alloa", "config_extension_exclusive_desc": "Wenn ogschoid, werdn alle anderen konfigurations Möglichkeitn überganga, a de Forge.", "global_level_registry": "Globale Registry", "org_level_registry": "Organisations-Registry", "registry_extension_endpoint": "Registry-Erweiterungs-Endpunkt", "secret_extension_endpoint": "Secret-Erweitarung-Endpunkt", "extension_netrc": "Dua Netrc Anmeldedaten dazua", "extension_netrc_desc": "Schig de Forge Netrc Anmeldetaten zua Erweiterung mid." } ================================================ FILE: web/src/assets/locales/cs.json ================================================ { "admin": { "settings": { "agents": { "add": "Přidat agent", "agents": "Agenti", "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "badge": "kapacita", "capacity": "Kapacita", "desc": "Maximální počet paralelních potrubí prováděných tímto agentem." }, "created": "Agent vytvořen", "delete_agent": "Odstranit agent", "delete_confirm": "Opravdu chcete tohoto agenta odstranit? Už se nebude moci připojit k serveru.", "deleted": "Agent smazán", "desc": "Agenti registrovaní na tomto serveru.", "edit_agent": "Upravit agent", "id": "ID", "last_contact": "Poslední kontakt", "name": { "name": "Název", "placeholder": "Jméno agenta" }, "never": "Nikdy", "no_schedule": { "name": "Zakázat agent", "placeholder": "Zastavení přebírání nových úkolů agentem" }, "none": "Zatím zde nejsou žádní agenti.", "platform": { "badge": "platforma", "platform": "Platforma" }, "save": "Uložit agent", "saved": "Agent uložen", "show": "Ukázat agenty", "token": "Tokeny", "version": "Verze" }, "not_allowed": "K nastavení serveru nemáte přístup.", "orgs": { "delete_confirm": "Opravdu chcete tuto organizaci smazat? Tím se odstraní také všechna úložiště vlastněná touto organizací.", "delete_org": "Odstranit organizaci", "deleted": "Organizace vymazána", "desc": "Organizace vlastnící repozitáře na tomto serveru.", "none": "Zatím neexistují žádné organizace.", "org_settings": "Organizační nastavení", "orgs": "Organizace", "view": "Zobrazit organizaci" }, "queue": { "agent": "agent", "desc": "Úlohy čekající na provedení agenty", "pause": "Pauza", "paused": "Fronta je pozastavena", "queue": "Fronta", "resume": "Resumé", "resumed": "Fronta je obnovena", "stats": { "completed_count": "Dokončené úkoly", "pending_count": "Čeká se na", "running_count": "Běhání", "waiting_on_deps_count": "Čekání na závislosti", "worker_count": "Zdarma" }, "task_pending": "Úkol je v řešení", "task_running": "Úloha je spuštěna", "task_waiting_on_deps": "Úloha čeká na závislosti", "tasks": "Úkoly", "waiting_for": "čekání na" }, "repos": { "desc": "Repozitáře, které jsou nebo byly na tomto serveru povoleny.", "disabled": "Bezbariérový", "none": "Zatím neexistují žádná úložiště.", "repos": "Repozitáře", "settings": "Repozitář nastavení", "view": "Zobrazit Repozitář" }, "secrets": { "add": "Přidat tajemství", "created": "Vytvoření globálního tajemství", "deleted": "Globální tajemství odstraněno", "desc": "Globální tajemství lze předat všem úložištím jednotlivých kroků pipeline za běhu jako proměnné prostředí.", "events": { "events": "Dostupné na následujících akcích", "pr_warning": "S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství." }, "images": { "desc": "Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný", "images": "Dostupné pro následující snímky" }, "name": "Název", "none": "Zatím neexistují žádná globální tajemství.", "plugins_only": "K dispozici pouze pro pluginy", "save": "Uložit tajemství", "saved": "Globální tajemství uloženo", "secrets": "Tajemství", "show": "Zobrazit tajemství", "value": "Hodnoty", "warning": "Tato tajemství budou k dispozici všem uživatelům serveru." }, "settings": "Nastavení", "users": { "add": "Přidat uživatele", "admin": { "admin": "Admin", "placeholder": "Uživatel je admin" }, "avatar_url": "Adresa URL avatara", "cancel": "Zrušit", "created": "Uživatel vytvořil", "delete_confirm": "Opravdu chcete tohoto uživatele odstranit? Tím se odstraní také všechna úložiště, která tento uživatel vlastní.", "delete_user": "Odstranění uživatele", "deleted": "Smazaný uživatel", "desc": "Uživatelé registrovaní pro tento server", "edit_user": "Upravit uživatele", "email": "E-mail", "login": "Přihlášení", "none": "Zatím nejsou žádní uživatelé.", "save": "Uložit uživatele", "saved": "Uživatel uložil", "show": "Zobrazit uživatele", "users": "Uživatelé" } } }, "api": "API", "back": "Zpět", "cancel": "Zrušit", "docs": "Doky", "documentation_for": "Dokumentace k \"{topic}\"", "errors": { "not_found": "Server nemohl najít požadovaný objekt" }, "login": "Přihlášení", "logout": "Odhlášení", "not_found": { "back_home": "Zpět na úvod", "not_found": "Páni 404, buď jsme něco rozbili, nebo jsi měl překlep :-/" }, "org": { "settings": { "not_allowed": "Nemáte přístup k nastavení této organizace", "secrets": { "add": "Přidat tajemství", "created": "Vytvořené organizační tajemství", "deleted": "Organizační tajemství vymazáno", "desc": "Tajemství organizace lze za běhu předat jednotlivým krokům pipeline úložiště všech organizací jako proměnné prostředí.", "events": { "events": "Dostupné na následujících akcích", "pr_warning": "S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství." }, "images": { "desc": "Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný", "images": "Dostupné pro následující snímky" }, "name": "Název", "none": "Zatím neexistují žádná tajemství organizace.", "plugins_only": "K dispozici pouze pro pluginy", "save": "Uložit tajemství", "saved": "Uložené tajemství organizace", "secrets": "Tajemství", "show": "Zobrazit tajemství", "value": "Hodnoty" }, "settings": "Nastavení" } }, "password": "Heslo", "pipeline_feed": "Přívodní potrubí", "repo": { "activity": "Aktivita", "add": "Přidat repozitář", "branches": "Pobočky", "deploy_pipeline": { "enter_target": "Prostředí cílového nasazení", "title": "Spuštění události nasazení pro aktuální potrubí #{pipelineId}", "trigger": "Nasazení", "variables": { "add": "Přidat proměnnou", "desc": "Zadejte další proměnné, které chcete použít v potrubí. Proměnné se stejným názvem budou přepsány.", "name": "Název proměnné", "title": "Dodatečné proměnné potrubí", "value": "Proměnná hodnota" } }, "enable": { "disabled": "Bezbariérový", "enable": "Povolit", "enabled": "Již povoleno", "list_reloaded": "Repozitář znovu načtený seznam", "reload": "Znovunačtení úložišť", "success": "Repozitář povoleno" }, "manual_pipeline": { "select_branch": "Vyberte větev", "title": "Spuštění ručního spuštění potrubí", "trigger": "Spustit potrubí", "variables": { "add": "Přidat proměnnou", "desc": "Zadejte další proměnné, které chcete použít v potrubí. Proměnné se stejným názvem budou přepsány.", "name": "Název proměnné", "title": "Další proměnné potrubí", "value": "Proměnná hodnota" } }, "not_allowed": "Nemáte povolen přístup k tomuto repozitář", "open_in_forge": "Otevřený repozitář v systému řízení verzí", "pipeline": { "actions": { "cancel": "Storno", "cancel_success": "Potrubí zrušeno", "canceled": "Tento krok byl zrušen.", "deploy": "Nasazení", "log_auto_scroll": "Automatické posouvání dolů", "log_auto_scroll_off": "Vypnutí automatického posouvání", "log_download": "Stáhnout", "restart": "Restart", "restart_success": "Opětovné spuštění potrubí" }, "config": "Konfigurace", "event": { "cron": "cron", "deploy": "Nasazení", "manual": "Manuál", "pr": "Žádost o stažení", "push": "Push", "tag": "Tag" }, "exit_code": "Kód ukončení {exitCode}", "files": "Změněné soubory ({files})", "loading": "Načítání…", "log_download_error": "Při stahování souboru protokolu došlo k chybě", "log_title": "Krokové protokoly", "no_files": "Žádné soubory nebyly změněny.", "no_pipeline_steps": "Nejsou k dispozici žádné kroky v potrubí!", "no_pipelines": "Žádné potrubí zatím nebylo spuštěno.", "pipeline": "Potrubí #{pipelineId}", "pipelines_for": "Potrubí pro větev \"{branch}\"", "pipelines_for_pr": "Potrubí pro požadavek na stažení #{index}", "protected": { "approve": "Schválit", "approve_success": "Potrubí schváleno", "awaits": "Toto potrubí čeká na schválení správcem!", "decline": "Pokles", "decline_success": "Potrubí kleslo", "declined": "Tento plynovod byl odmítnut!", "review": "Přezkoumání změn" }, "status": { "blocked": "blokované", "declined": "odmítnuto", "error": "chyba", "failure": "selhání", "killed": "zabil", "pending": "čeká na", "running": "běžící", "skipped": "přeskočil", "started": "začal", "status": "Stav: {status}", "success": "úspěch" }, "step_not_started": "Tento krok ještě nebyl zahájen.", "tasks": "Úkoly" }, "pull_requests": "Žádosti o stažení", "settings": { "actions": { "actions": "Akce", "delete": { "confirm": "Po této akci budou všechna data ztracena!!!\n\nOpravdu chcete pokračovat?", "delete": "Odstranit repozitář", "success": "Repozitář smazáno" }, "disable": { "disable": "Zakázat repozitář", "success": "Repozitář zakázáno" }, "enable": { "enable": "Povolit repozitář", "success": "Repozitář povoleno" }, "repair": { "repair": "Oprava repozitář", "success": "Repozitář opravené" } }, "badge": { "badge": "Odznak", "branch": "Pobočka", "type": "Syntaxe", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL" }, "crons": { "add": "Přidat cron", "branch": { "placeholder": "Větev (pokud je prázdná, použije se výchozí větev)", "title": "Pobočka" }, "created": "Cron vytvořil", "crons": "Crons", "delete": "Odstranit cron", "deleted": "Cron odstraněn", "desc": "K pravidelnému spouštění potrubí lze použít úlohy Cron.", "edit": "Upravit cron", "name": { "name": "Název", "placeholder": "Název úlohy cron" }, "next_exec": "Další provedení", "none": "Zatím zde nejsou žádné crony.", "not_executed_yet": "Zatím neprovedeno", "run": "Běžte nyní", "save": "Uložit cron", "saved": "Cron uložen", "schedule": { "placeholder": "Harmonogram", "title": "Harmonogram (na základě UTC)" }, "show": "Zobrazit crons" }, "general": { "allow_pr": { "allow": "Povolit žádosti o stažení", "desc": "Potrubí lze spouštět na základě požadavků na stažení." }, "cancel_prev": { "cancel": "Zrušení předchozích potrubí", "desc": "Umožňuje zrušit čekající a spuštěné pipeline stejné události a kontextu před spuštěním nově spuštěné pipeline." }, "general": "Obecné", "netrc_only_trusted": { "desc": "Pověření netrc vkládejte pouze do důvěryhodných kontejnerů (doporučeno).", "netrc_only_trusted": "Pověření netrc vkládat pouze do důvěryhodných kontejnerů" }, "pipeline_path": { "default": "Ve výchozím nastavení: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Cesta ke konfiguraci potrubí (například {0}). Složky by měly končit znakem {1}.", "desc_path_example": "moje/cesta/", "path": "Cesta potrubí" }, "project": "Nastavení projektu", "protected": { "desc": "Každý plynovod musí být před provedením schválen.", "protected": "Chráněný" }, "save": "Uložit nastavení", "success": "Repozitář aktualizace nastavení", "timeout": { "minutes": "minuty", "timeout": "Časový limit" }, "trusted": { "desc": "Základní kontejnery potrubí získávají přístup k rozšířeným možnostem, jako je například připojování svazků.", "trusted": "Důvěryhodný" }, "visibility": { "internal": { "desc": "Tento projekt mohou vidět pouze ověření uživatelé instance programu Woodpecker.", "internal": "Interní" }, "private": { "desc": "Pouze vy a ostatní majitelé repozitáře mohou tento projekt vidět.", "private": "Soukromé" }, "public": { "desc": "Každý uživatel může vidět váš projekt, aniž by byl přihlášen.", "public": "Veřejnost" }, "visibility": "Zviditelnění projektu" } }, "not_allowed": "Nemáte přístup k nastavení tohoto úložiště", "registries": { "add": "Přidat rejstřík", "address": { "address": "Adresa", "placeholder": "Adresa registru (např. docker.io)" }, "created": "Vytvořená pověření k registru", "credentials": "Pověření k registraci", "delete": "Odstranění registru", "deleted": "Odstranění pověření registru", "desc": "Lze přidat pověření k registrům a používat soukromé obrazy pro potrubí.", "edit": "Upravit registr", "none": "V registru zatím nejsou žádná pověření.", "registries": "Registry", "save": "Uložit registr", "saved": "Uložená pověření k registru", "show": "Zobrazit registry" }, "secrets": { "add": "Přidat tajemství", "created": "Tajemství vytvořeno", "delete": "Odstranit tajemství", "delete_confirm": "Opravdu chcete toto tajemství vymazat?", "deleted": "Tajemství odstraněno", "desc": "Tajemství lze předávat jednotlivým krokům potrubí za běhu jako proměnné prostředí.", "edit": "Upravit tajemství", "events": { "events": "Dostupné na následujících akcích", "pr_warning": "S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství." }, "images": { "desc": "Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný", "images": "Dostupné pro následující obrázky" }, "name": "Název", "none": "Žádná tajemství zatím neexistují.", "plugins_only": "K dispozici pouze pro pluginy", "save": "Uložit tajemství", "saved": "Tajemství uloženo", "secrets": "Tajemství", "show": "Zobrazit tajemství", "value": "Hodnoty" }, "settings": "Nastavení" }, "user_none": "Tato organizace / uživatel zatím nemá žádné projekty." }, "repos": "Repozitář", "repositories": "Repozitáře", "search": "Hledání…", "time": { "days_short": "d", "hours_short": "h", "min_short": "min", "not_started": "zatím nezačal", "sec_short": "sek", "template": "MMM D, RRRR, HH:mm z", "weeks_short": "t" }, "unknown_error": "Došlo k neznámé chybě", "url": "URL", "user": { "access_denied": "Nejste oprávněni se přihlásit", "internal_error": "Došlo k nějaké interní chybě", "oauth_error": "Chyba při ověřování proti poskytovateli OAuth", "settings": { "api": { "api": "API", "api_usage": "Příklad využití API", "cli_usage": "Příklad použití CLI", "desc": "Osobní přístupový token a používání API", "dl_cli": "Stáhnout CLI", "reset_token": "Resetovat token", "shell_setup": "Nastavení shellu", "shell_setup_before": "proveďte kroky nastavení shellu před", "swagger_ui": "Rozhraní Swagger UI", "token": "Osobní přístupový token" }, "general": { "general": "Obecné", "language": "Jazyk" }, "secrets": { "add": "Přidat tajemství", "created": "Vytvoření uživatelského tajemství", "deleted": "Vymazání uživatelského tajemství", "desc": "Uživatelská tajemství lze za běhu předávat jednotlivým krokům pipeline všech uživatelských úložišť jako proměnné prostředí.", "events": { "events": "Dostupné na následujících akcích", "pr_warning": "S touto možností buďte opatrní, protože špatný subjekt může odeslat škodlivý požadavek na stažení, který odhalí vaše tajemství." }, "images": { "desc": "Seznam obrázků oddělených čárkou, u kterých je toto tajemství k dispozici, pokud chcete povolit všechny obrázky, nechte prázdný", "images": "Dostupné pro následující snímky" }, "name": "Název", "none": "Zatím nejsou k dispozici žádná uživatelská tajemství.", "plugins_only": "Dostupné pouze pro zásuvné moduly", "save": "Uložit tajemství", "saved": "Uložený uživatelský sekret", "secrets": "Tajemství", "show": "Zobrazit tajemství", "value": "Hodnoty" }, "settings": "Uživatelská nastavení" } }, "username": "Uživatelské jméno", "welcome": "Vítejte ve Woodpecker" } ================================================ FILE: web/src/assets/locales/de.json ================================================ { "admin": { "settings": { "agents": { "add": "Agent hinzufügen", "agents": "Agenten", "backend": { "backend": "Backend", "badge": "Backend" }, "capacity": { "badge": "Kapazität", "capacity": "Kapazität", "desc": "Die maximale Anzahl von Pipelines, die ein Agent ausführt." }, "created": "Agent erstellt", "delete_agent": "Agent löschen", "delete_confirm": "Wollen Sie diesen Agent wirklich löschen? Dieser kann sich dann nicht mehr mit dem Server verbinden.", "deleted": "Agent gelöscht", "desc": "Für diesen Server registrierte Agenten.", "edit_agent": "Agent bearbeiten", "id": "ID", "last_contact": { "last_contact": "Letzter Kontakt", "badge": "letzter Kontakt" }, "name": { "name": "Name", "placeholder": "Name des Agenten" }, "never": "Nie", "no_schedule": { "name": "Agent deaktivieren", "placeholder": "Agent daran hindern, neue Aufgaben zu nehmen" }, "none": "Es gibt noch keine Agenten.", "platform": { "badge": "Plattform", "platform": "Plattform" }, "save": "Agent speichern", "saved": "Agent gespeichert", "show": "Agenten anzeigen", "token": "Schlüssel", "version": "Version", "org": { "badge": "Organisation" }, "custom_labels": { "desc": "Die benutzerdefinierten Labels, die vom Agent-Administrator beim Start des Agenten festgelegt wurden.", "custom_labels": "Benutzerdefinierte Labels" } }, "not_allowed": "Du darfst nicht auf die Server-Einstellungen zugreifen", "orgs": { "delete_confirm": "Möchtest du diese Organisation wirklich löschen? Das wird auch alle Repositorys löschen, die dieser Organisation gehören.", "delete_org": "Organisation löschen", "deleted": "Organisation gelöscht", "desc": "Organisationen, die Repositorys auf diesem Server besitzen.", "none": "Es gibt noch keine Organisationen.", "org_settings": "Organisations-Einstellungen", "orgs": "Organisationen", "view": "Organisation anzeigen" }, "queue": { "agent": "Agent", "desc": "Aufgaben, die darauf warten, von Agenten ausgeführt zu werden.", "pause": "Pausieren", "paused": "Warteschlange wurde pausiert", "queue": "Warteschlange", "resume": "Wieder aufnehmen", "resumed": "Warteschlange wurde wieder aufgenommen", "stats": { "completed_count": "Beendete Aufgaben", "pending_count": "Ausstehend", "running_count": "Läuft", "waiting_on_deps_count": "Wartet auf Abhängigkeiten", "worker_count": "Frei" }, "task_pending": "Aufgabe steht aus", "task_running": "Aufgabe läuft", "task_waiting_on_deps": "Aufgabe wartet auf Abhängigkeiten", "tasks": "Aufgaben", "waiting_for": "wartet auf" }, "repos": { "desc": "Repositorys, die auf dem Server aktiviert sind oder waren.", "disabled": "Deaktiviert", "none": "Es gibt noch keine Repositorys.", "repair": { "repair": "Alle reparieren", "success": "Repositorys repariert" }, "repos": "Repositorys", "settings": "Repository-Einstellungen", "view": "Repository anzeigen" }, "secrets": { "add": "Geheimnis hinzufügen", "created": "Globales Geheimnis erstellt", "deleted": "Globales Geheimnis gelöscht", "desc": "Globale Geheimnisse können in Pipelines in allen Repositorys genutzt werden.", "events": { "events": "Verfügbar für folgende Ereignisse", "pr_warning": "Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte." }, "images": { "desc": "Liste aller Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Images zu erlauben", "images": "Verfügbar für folgende Images" }, "name": "Name", "none": "Es gibt noch keine globalen Geheimnisse.", "plugins_only": "Nur für Plugins verfügbar", "save": "Geheimnis speichern", "saved": "Globales Geheimnis gespeichert", "secrets": "Geheimnisse", "show": "Geheimnisse anzeigen", "value": "Wert", "warning": "Diese Geheimnisse können von allen Nutzern eingesehen werden." }, "settings": "Einstellungen", "users": { "add": "Benutzer hinzufügen", "admin": { "admin": "Admin", "placeholder": "Benutzer ist ein Admin" }, "avatar_url": "URL des Profilbilds", "cancel": "Abbrechen", "created": "Benutzer erstellt", "delete_confirm": "Möchtest du diesen Benutzer wirklich löschen? Das wird auch alle Repositorys löschen, die diesem Benutzer gehören.", "delete_user": "Benutzer löschen", "deleted": "Benutzer gelöscht", "desc": "Auf diesem Server registrierte Benutzer.", "edit_user": "Benutzer bearbeiten", "email": "E-Mail", "login": "Benutzername", "none": "Es gibt noch keine Benutzer.", "save": "Benutzer speichern", "saved": "Benutzer gespeichert", "show": "Benutzer anzeigen", "users": "Benutzer" }, "registries": { "desc": "Globale Registry-Zugangsdaten können hinzugefügt werden, um für private Images für alle Pipelines zu verwenden.", "warning": "Diese Register-Zugangsdaten werden für alle Benutzer verfügbar sein." } } }, "api": "API", "back": "Zurück", "cancel": "Abbrechen", "default": "Standard", "docs": "Dokumentation", "documentation_for": "Dokumentation für „{topic}“", "empty_list": "Keine {entity} gefunden!", "errors": { "not_found": "Angefragtes Objekt wurde nicht gefunden" }, "global_level_secret": "globales Geheimnis", "info": "Info", "login": "Anmelden", "logout": "Abmelden", "not_found": { "back_home": "Zurück zum Start", "not_found": "Whoa 404, entweder haben wir etwas kaputt gemacht oder du hattest einen Tippfehler :-/" }, "org": { "settings": { "not_allowed": "Du darfst nicht auf die Einstellungen dieser Organisation zugreifen", "secrets": { "add": "Geheimnis hinzufügen", "created": "Organisations-Geheimnis erstellt", "deleted": "Organisations-Geheimnis gelöscht", "desc": "Organisation-Geheimnisse können in allen Pipelines in Repositorys, die der Organisation gehören, genutzt werden.", "events": { "events": "Verfügbar für folgende Ereignisse", "pr_warning": "Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte." }, "images": { "desc": "Liste aller Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Images zu erlauben", "images": "Verfügbar für die folgenden Images" }, "name": "Name", "none": "Es existieren noch keine Organisations-Geheimnisse.", "plugins_only": "Nur für Plugins verfügbar", "save": "Geheimnis speichern", "saved": "Organisations-Geheimnis gespeichert", "secrets": "Geheimnisse", "show": "Geheimnisse anzeigen", "value": "Wert" }, "settings": "Einstellungen", "registries": { "desc": "Zugangsdaten zum Register der Organisation können für private Images aller Pipelines der Organisation verwendet werden." }, "agents": { "desc": "Agenten registriert für diese Organisation." } } }, "org_level_secret": "Organisationsgeheimnis", "password": "Passwort", "pipeline_feed": "Pipeline-Feed", "repo": { "activity": "Aktivitäten", "add": "Repository hinzufügen", "branches": "Branches", "deploy_pipeline": { "enter_target": "Zielumgebung des Deployments", "title": "Ein Deployment-Event für die aktuelle Pipeline #{pipelineId} starten", "trigger": "Deploy", "variables": { "add": "Variable hinzufügen", "desc": "Zusätzliche Variablen für diese Pipeline hinzufügen. Variablen mit dem gleichen Namen werden überschrieben.", "name": "Variablenname", "title": "Zusätzliche Pipeline-Variablen", "value": "Variablenwert", "delete": "Variable löschen" }, "enter_task": "Aufgabe des Deployments" }, "enable": { "disabled": "Deaktiviert", "enable": "Aktivieren", "enabled": "Bereits aktiviert", "list_reloaded": "Repository-Liste neu geladen", "reload": "Repositorys neu laden", "success": "Repository aktiviert", "new_forge_repo": "neues Repo auf der Code-Plattform", "stale_wp_repo": "veraltetes Woodpecker-Repo", "conflict": "Konflikt", "conflict_desc": "Dieses Repository wurde auf der Code-Plattform mit einer neuen ID erstellt, aber ein alter Eintrag mit demselben Namen existiert noch in Woodpecker. Lösche das alte Repo, um das neue zu aktivieren, oder repariere das alte.", "forge_repo_missing": "Repo auf der Code-Plattform fehlt!" }, "manual_pipeline": { "select_branch": "Branch auswählen", "title": "Löse einen manuellen Pipeline Durchlauf aus", "trigger": "Pipeline ausführen", "variables": { "add": "Variable hinzufügen", "desc": "Füge weitere Variablen für deine Pipeline hinzu. Variablen mit demselben Namen werden überschrieben.", "name": "Variablenname", "title": "Zusätzliche Pipeline-Variablen", "value": "Variablenwert", "delete": "Variable löschen" }, "show_pipelines": "Pipelines anzeigen", "no_manual_workflows": "Keine manuell ausführbaren Workflows gefunden. Stelle sicher, dass es mindestens einen Workflow gibt, der beim „manual“-Event läuft." }, "not_allowed": "Zugriff auf dieses Repository nicht erlaubt", "open_in_forge": "Repository in der Code-Plattform öffnen", "pipeline": { "actions": { "cancel": "Abbrechen", "cancel_success": "Pipeline abgebrochen", "canceled": "Dieser Schritt wurde abgebrochen.", "deploy": "Deploy", "log_auto_scroll": "Automatisches Scrollen aktivieren", "log_auto_scroll_off": "Automatisches Scrollen deaktivieren", "log_download": "Herunterladen", "restart": "Neustarten", "restart_success": "Pipeline neu gestartet", "log_delete": "Löschen", "skipped": "Dieser Schritt wurde übersprungen.", "expand_all": "Alle ausklappen", "collapse_all": "Alle einklappen" }, "config": "Konfiguration", "errors": "Fehler", "event": { "cron": "Cron", "deploy": "Deploy", "manual": "Manuell", "pr": "Pull-Request", "push": "Push", "tag": "Tag", "release": "Release", "pr_closed": "Pull-Request zusammengeführt/geschlossen", "pr_metadata": "Pull-Request-Metadaten geändert" }, "exit_code": "Exit-Code {exitCode}", "files": "Geänderte Dateien", "loading": "Laden…", "log_download_error": "Beim Herunterladen der Log-Datei ist ein Fehler aufgetreten", "log_title": "Logs des Schrittes", "no_files": "Es wurden keine Dateien geändert.", "no_pipeline_steps": "Keine Schritte in der Pipeline vorhanden!", "no_pipelines": "Bisher wurden noch keine Pipelines gestartet.", "pipeline": "Pipeline #{pipelineId}", "pipelines_for": "Pipelines für den Branch „{branch}“", "pipelines_for_pr": "Pipelines für Pull-Request #{index}", "protected": { "approve": "Genehmigen", "approve_success": "Pipeline genehmigt", "awaits": "Diese Pipeline wartet auf die Genehmigung durch einen Maintainer!", "decline": "Ablehnen", "decline_success": "Pipeline abgelehnt", "declined": "Diese Pipeline ist abgelehnt worden!", "review": "Änderungen überprüfen" }, "show_errors": "Fehler anzeigen", "status": { "blocked": "blockiert", "declined": "abgelehnt", "error": "Fehler", "failure": "fehlgeschlagen", "killed": "getötet", "pending": "ausstehend", "running": "laufend", "skipped": "übersprungen", "started": "gestartet", "status": "Status: {status}", "success": "erfolgreich", "canceled": "abgebrochen" }, "step_not_started": "Dieser Schritt hat noch nicht begonnen.", "tasks": "Vorgänge", "warnings": "Warnungen", "we_got_some_errors": "Oh nein, ein Fehler ist aufgetreten!", "log_delete_confirm": "Möchtest du die Logs dieses Schrittes wirklich löschen?", "log_delete_error": "Ein Fehler ist beim Löschen der Logs des Schrittes aufgetreten", "duration": "Pipeline-Dauer: {duration}", "created": "Erstellt: {created}", "no_logs": "Keine Logs", "debug": { "no_permission": "Du bist nicht berechtigt, auf die Debug-Informationen zuzugreifen.", "download_metadata": "Metadaten herunterladen", "metadata_download_successful": "Die Metadaten wurden erfolgreich heruntergeladen", "metadata_download_error": "Fehler beim Herunterladen der Metadaten", "title": "Debug", "metadata_exec_title": "Pipeline lokal erneut ausführen", "metadata_exec_desc": "Lade die Metadaten dieser Pipeline herunter, um diese lokal auszuführen. Dies erlaubt dir, Fehler zu beheben und Änderungen zu testen, bevor du einen Commit erstellst. Das Woodpecker-CLI muss in der zum Server passenden Version lokal installiert sein." }, "view": "Pipeline anzeigen", "cancel_info": { "superseded_by": "Ersetzt durch #{pipelineId}", "canceled_by_user": "Abgebrochen von {user}", "canceled_by_step": "Wegen Schritt {step} abgebrochen" }, "load_more": "Mehr laden", "version": "Die Woodpecker-Version, mit der diese Pipeline ausgeführt wurde.", "version_header": "Woodpecker-Version" }, "pull_requests": "Pull-Requests", "settings": { "actions": { "actions": "Aktionen", "delete": { "confirm": "Alle Daten sind nach dieser Aktion verloren!\n\nMöchtest du wirklich fortfahren?", "delete": "Repository löschen", "success": "Repository gelöscht" }, "disable": { "disable": "Repository deaktivieren", "success": "Repository deaktiviert" }, "enable": { "enable": "Repository aktivieren", "success": "Repository aktiviert" }, "repair": { "repair": "Repository reparieren", "success": "Repository repariert" } }, "badge": { "badge": "Abzeichen", "branch": "Branch", "type": "Syntax", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL", "events": "Events", "workflow": "Workflow", "step": "Schritt" }, "crons": { "add": "Cron hinzufügen", "branch": { "placeholder": "Branch (verwendet Standard-Branch wenn leer)", "title": "Branch" }, "created": "Cron erstellt", "crons": "Crons", "delete": "Cron löschen", "deleted": "Cron gelöscht", "desc": "Cron-Jobs können dazu verwendet werden in regelmäßigen Abständen Pipelines zu starten.", "edit": "Cron bearbeiten", "name": { "name": "Name", "placeholder": "Name des Cron" }, "next_exec": "Nächste Ausführung", "none": "Es gibt noch keine Crons.", "not_executed_yet": "Noch nicht ausgeführt", "run": "Jetzt ausführen", "save": "Cron speichern", "saved": "Cron gespeichert", "schedule": { "placeholder": "Zeitplan", "title": "Zeitplan (basierend auf UTC)" }, "show": "Crons anzeigen", "enabled": "Aktiv" }, "general": { "allow_pr": { "allow": "Pull-Requests zulassen", "desc": "Die Ausführung von Pipelines für Pull-Requests erlauben." }, "cancel_prev": { "cancel": "Ältere Pipelines abbrechen", "desc": "Bei ausgewählten Events werden laufende Pipelines desselben Ereignisses abgebrochen, bevor die neue Pipeline startet." }, "general": "Projekt", "netrc_only_trusted": { "desc": "Plugins, die Zugriff auf Netrc-Zugangsdaten erhalten, die genutzt werden können, um Repositorys von der Code-Plattform zu klonen oder zu pushen.", "netrc_only_trusted": "Eigene vertrauenswürdige Plugins zum Klonen" }, "pipeline_path": { "default": "Standardmäßig: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Pfad zu deiner Pipeline-Konfiguration (z. B. {0}). Verzeichnisse sollten mit einem {1} enden.", "desc_path_example": "mein/pfad/", "path": "Pipeline-Pfad" }, "project": "Projekt-Einstellungen", "protected": { "desc": "Jede Pipeline muss genehmigt werden, bevor sie ausgeführt wird.", "protected": "Geschützt" }, "save": "Einstellungen speichern", "success": "Projekt-Einstellungen aktualisiert", "timeout": { "minutes": "Minuten", "timeout": "Zeitlimit" }, "trusted": { "desc": "Die zugrundeliegenden Pipeline-Container erhalten Zugriff auf ausgeweitete Funktionen (z. B. das Einhängen von Laufwerken).", "trusted": "Vertrauenswürdig", "network": { "network": "Netzwerk", "desc": "Pipeline-Container erhalten Zugriff auf Netzwerk-Privilegien wie das Ändern des DNS." }, "volumes": { "volumes": "Volumen", "desc": "Pipeline-Container können Volumen einhängen." }, "security": { "security": "Sicherheit", "desc": "Pipeline-Container erhalten Zugriff auf Sicherheits-Privilegien." } }, "visibility": { "internal": { "desc": "Nur authentifizierte Benutzer der Woodpecker-Instanz können dieses Projekt sehen.", "internal": "Intern" }, "private": { "desc": "Nur du und andere Besitzer des Repositorys können dieses Projekt sehen.", "private": "Privat" }, "public": { "desc": "Jeder Benutzer kann dein Projekt sehen, ohne eingeloggt zu sein.", "public": "Öffentlich" }, "visibility": "Sichtbarkeit des Projekts" }, "allow_deploy": { "allow": "Deployment-Events erlauben", "desc": "Deployments von erfolgreichen Pipelines erlauben. Alle Benutzer mit Push-Zugriff können diese auslösen, mit Vorsicht verwenden." } }, "not_allowed": "Zugriff auf die Einstellungen dieses Repositorys nicht erlaubt", "registries": { "add": "Registry hinzufügen", "address": { "address": "Adresse", "placeholder": "Registry-Adresse (z. B. docker.io)" }, "created": "Registry-Zugangsdaten erstellt", "credentials": "Zugangsdaten für die Registry", "delete": "Registry löschen", "deleted": "Registry-Zugangsdaten gelöscht", "desc": "Zugangsdaten für die Registries können hinzugefügt werden, um private Images für deine Pipelines zu verwenden.", "edit": "Registry bearbeiten", "none": "Es gibt noch keine Zugangsdaten für die Registry.", "registries": "Registries", "save": "Registry speichern", "saved": "Registry-Zugangsdaten gespeichert", "show": "Registries anzeigen" }, "secrets": { "add": "Geheimnis hinzufügen", "created": "Geheimnis erstellt", "delete": "Geheimnis löschen", "delete_confirm": "Möchtest du dieses Geheimnis wirklich löschen?", "deleted": "Geheimnis gelöscht", "desc": "Geheimnisse können zur Laufzeit als Umgebungsvariablen an einzelne Pipelineschritte übergeben werden.", "edit": "Geheimnis bearbeiten", "events": { "events": "Verfügbar bei folgenden Ereignissen", "pr_warning": "Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte." }, "images": { "desc": "Liste der Images, für die dieses Geheimnis verfügbar ist; leer lassen, um alle Images zuzulassen", "images": "Verfügbar für folgende Images" }, "name": "Name", "none": "Es gibt noch keine Geheimnisse.", "plugins_only": "Nur für Plugins verfügbar", "save": "Geheimnis speichern", "saved": "Geheimnis gespeichert", "secrets": "Geheimnisse", "show": "Geheimnisse anzeigen", "value": "Wert" }, "settings": "Einstellungen" }, "user_none": "Diese Organisation / dieser Benutzer hat noch keine Repositorys", "visibility": { "visibility": "Projektsichtbarkeit", "public": { "public": "Öffentlich", "desc": "Jeder kann dein Projekt sehen, ohne eingeloggt zu sein." }, "private": { "private": "Privat", "desc": "Nur du und andere Besitzer des Repositorys können dieses Projekt sehen." }, "internal": { "internal": "Intern", "desc": "Nur authentifizierte Benutzer der Woodpecker-Instanz können dieses Projekt sehen." } } }, "repos": "Repos", "repositories": { "title": "Repositorys", "all": { "title": "Alle Repositorys", "desc": "Repositorys nach der letzten Erstellung einer Pipeline sortiert" }, "last": { "title": "Zuletzt besucht", "desc": "Zuletzt besuchte Repositorys nach Zugriffszeit sortiert" } }, "running_version": "Du verwendest Woodpecker {0}", "search": "Suche…", "time": { "days_short": "t", "hours_short": "h", "min_short": "min", "not_started": "noch nicht gestartet", "sec_short": "sek", "template": "DD.MM.YYYY, HH:mm z", "weeks_short": "w", "just_now": "gerade eben" }, "unknown_error": "Ein unbekannter Fehler ist aufgetreten", "update_woodpecker": "Du solltest deine Woodpecker-Instanz auf {0} aktualisieren", "url": "URL", "user": { "access_denied": "Du bist nicht berechtigt, dich anzumelden", "internal_error": "Ein interner Fehler ist aufgetreten", "oauth_error": "Fehler bei der Authentifizierung gegen OAuth-Anbieter", "settings": { "api": { "api": "API", "api_usage": "Beispiel zur Nutzung der API", "cli_usage": "Beispiel zur Nutzung des CLI", "desc": "Persönlicher Zugangsschlüssel und API-Nutzung", "dl_cli": "CLI herunterladen", "reset_token": "Zugangsschlüssel zurücksetzen", "shell_setup": "Kommandozeilen-Einrichtung", "shell_setup_before": "führe bitte die Schritte zur Einrichtung der Kommandozeile vorher aus", "swagger_ui": "Swagger-UI", "token": "Persönlicher Zugangsschlüssel" }, "general": { "general": "Konto", "language": "Sprache", "theme": { "auto": "Automatisch", "dark": "Dunkel", "light": "Hell", "theme": "Thema" } }, "secrets": { "add": "Geheimnis hinzufügen", "created": "Benutzer-Geheimnis erstellt", "deleted": "Benutzer-Geheimnis gelöscht", "desc": "Benutzer-Geheimnisse können in allen Pipelines in Repositorys, die dem Benutzer gehören, genutzt werden.", "events": { "events": "Verfügbar für folgende Ereignisse", "pr_warning": "Bitte sei vorsichtig mit dieser Option, da eine böswillige Person über einen Pull-Request deine Geheimnisse erhalten könnte." }, "images": { "desc": "Komma getrennte Liste aller Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Images zu erlauben", "images": "Verfügbar für die folgenden Images" }, "name": "Name", "none": "Es existieren noch keine Benutzer-Geheimnisse.", "plugins_only": "Nur für Plugins verfügbar", "save": "Geheimnis speichern", "saved": "Benutzer-Geheimnis gespeichert", "secrets": "Geheimnisse", "show": "Geheimnisse anzeigen", "value": "Wert" }, "settings": "Benutzereinstellungen", "cli_and_api": { "desc": "Persönlicher Zugangsschlüssel, CLI- und API-Nutzung", "token": "Persönlicher Zugangsschlüssel", "api_usage": "Beispiel zur Nutzung des API", "download_cli": "CLI herunterladen", "reset_token": "Schlüssel zurücksetzen", "swagger_ui": "Swagger-UI", "cli_and_api": "CLI/API", "cli_usage": "Beispiel zur Nutzung des CLI" }, "registries": { "desc": "Register-Zugangsdaten des Benutzers können für private Images aller persönlichen Pipelines verwendet werden." }, "agents": { "desc": "Agenten, die für die Repositorys deines Benutzers registriert sind." } } }, "username": "Benutzername", "welcome": "Willkommen bei Woodpecker", "login_to_cli": "In CLI anmelden", "login_to_cli_description": "Wenn du fortfährst, wirst du im CLI angemeldet.", "abort": "Abbrechen", "cli_login_success": "Anmelden im CLI erfolgreich", "cli_login_failed": "Anmelden im CLI fehlgeschlagen", "cli_login_denied": "Anmelden im CLI abgelehnt", "return_to_cli": "Du kannst diesen Tab jetzt schließen und zur CLI zurückkehren.", "secrets": { "secrets": "Geheimnisse", "desc": "Geheimnisse können in allen Pipelines des Repositorys genutzt werden.", "none": "Es gibt noch keine Geheimnisse.", "add": "Geheimnis hinzufügen", "save": "Geheimnis speichern", "show": "Geheimnisse anzeigen", "name": "Name", "value": "Wert", "delete_confirm": "Möchtest du dieses Geheimnis wirklich löschen?", "deleted": "Geheimnis gelöscht", "created": "Geheimnis erstellt", "saved": "Geheimnis gespeichert", "images": { "images": "Verfügbar für die folgenden Images", "desc": "Liste der Images, für die dieses Geheimnis verfügbar ist; leer lassen, um für alle Images zuzulassen." }, "events": { "events": "Verfügbar bei den folgenden Ereignissen", "pr_warning": "Bitte sei vorsichtig mit dieser Option: Eine böswillige Person könnte über einen Pull-Request deine Geheimnisse erhalten.", "warning": "Geheimnisse in Pull-Requests zu erlauben könnte dazu führen, dass ein böswilliger Akteur diese mit einem Pull-Request stiehlt." }, "plugins_only": "Nur für Plugins verfügbar", "edit": "Geheimnis bearbeiten", "delete": "Geheimnis löschen", "plugins": { "images": "Nur für die folgenden Plugins verfügbar", "desc": "Liste aller Plugin-Images, für die dieses Geheimnis verwendet werden kann. Freilassen, um alle Plugins und allgemeine Schritte zu erlauben." }, "note": "Notiz" }, "settings": "Einstellungen", "oauth_error": "Fehler bei der Authentifizierung mit OAuth-Anbieter", "internal_error": "Interner Fehler aufgetreten", "registration_closed": "Die Registrierung ist geschlossen", "access_denied": "Du darfst nicht auf diese Instanz zugreifen", "registries": { "delete_confirm": "Möchtest du dieses Register wirklich löschen?", "address": { "desc": "Register-Adresse (z. B. docker.io)", "address": "Adresse" }, "credentials": "Register-Zugangsdaten", "none": "Es sind noch keine Registry-Zugangsdaten verfügbar.", "registries": "Register", "desc": "Register-Zugangsdaten können hinzugefügt werden, um private Images für Pipelines zu nutzen.", "deleted": "Register-Zugangsdaten gelöscht", "save": "Speicher Registry", "add": "Register hinzufügen", "view": "Register ansehen", "edit": "Register bearbeiten", "delete": "Register löschen", "created": "Register-Zugangsdaten erstellt", "saved": "Register-Zugangsdaten gespeichert", "show": "Register anzeigen" }, "invalid_state": "Der OAuth-Status ist ungültig", "by_user": "von {user}", "pushed_to": "gepusht auf", "closed": "geschlossen", "deployed_to": "Deployment ausgeführt auf", "created": "erstellt", "triggered": "ausgelöst", "pipeline_duration": "Pipeline-Dauer", "pipeline_since": "Die Pipeline wurde vor {created} Minuten erstellt", "pipeline_has_warnings": "Die Pipeline hat Warnungen", "pipeline_has_errors": "Die Pipeline hat Fehler", "login_with": "Mit {forge} anmelden", "all_repositories": "Alle Repositorys", "no_search_results": "Keine Ergebnisse gefunden", "require_approval": { "forks": "Pull-Request von geforkten Repositorys", "require_approval_for": "Erfordert Genehmigung für", "none": "Keine Genehmigung erforderlich", "none_desc": "Diese Einstellung kann gefährlich sein und sollte nur in privaten Repositories verwendet werden, in denen allen Benutzern vertraut wird.", "pull_requests": "Alle Pull-Requests", "all_events": "Alle Ereignisse von der Code-Plattform", "desc": "Verhindere, dass bösartige Pipelines Geheimnisse preisgeben oder schädliche Aufgaben ausführen, indem du diese vor der Ausführung genehmigst.", "allowed_users": { "desc": "Pipelines von diesen Benutzern erfordern nie eine Genehmigung.", "allowed_users": "Zugelassene Benutzer" } }, "org_access_denied": "Zugriff auf diese Organisation nicht erlaubt", "exit_fullscreen": "Vollbild verlassen", "fullscreen": "Vollbild", "oauth_host": "OAuth-Host", "merge_ref": "Zusammenführungs-Referenz", "merge_ref_desc": "Referenz, die für die Zusammenführungs-Basis genutzt wird. Dies wird für die Unterschiede bei Pull-Requests verwerdet.", "public_only": "Nur öffentlich", "public_only_desc": "Nur öffentliche Repositorys anzeigen.", "git_username": "Git-Benutzername", "git_password": "Git-Passwort", "executable_desc": "Pfad zur Programmdatei des Add-ons.", "save": "Speichern", "add": "Hinzufügen", "skip_verify": "SSL-Verifikation überspringen", "advanced_options": "Erweiterte Optionen", "leave_empty_to_keep_current_value": "Leer lassen, um den aktuellen Wert beizubehalten", "forge_deleted": "Plattform gelöscht", "login_to_woodpecker_with": "In Woodpecker anmelden mit", "forges": "Plattformen", "forges_desc": "Die Plattformen konfigurieren, auf denen Repositorys gehostet sind, für die Woodpecker verwendet wird.", "add_forge": "Plattform hinzufügen", "show_forges": "Plattformen anzeigen", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Add-on", "forge_type": "Plattform-Typ", "oauth_client_id": "OAuth-Client-ID", "oauth_client_secret": "OAuth-Client-Geheimnis", "git_username_desc": "Benutzername für den Git-Benutzer.", "git_password_desc": "Password oder persönlicher Zugangsschlüssel für den Git-Benutzer.", "executable": "Programmdatei", "skip_verify_desc": "SSL-Verifikation für API-Verbindungen überspringen. Im Produktiveinsatz nicht empfohlen.", "forge_managed_by_env": "Die primäre Plattform wird durch Umgebungsvariablen verwaltet. Jede Änderung dieser Plattform wird bei einem Neustart verworfen.", "oauth_redirect_uri": "OAuth-Weiterleitungs-URI", "forge_created": "Plattform erstellt", "forge_delete_confirm": "Möchtest du diese Plattform wirklich löschen? Dies wird auch alle Repositorys, Benutzer und Pipelines dieser Plattform löschen.", "edit_forge": "Plattform bearbeiten", "delete_forge": "Plattform löschen", "no_forges": "Es gibt noch keine Plattformen.", "use_this_redirect_uri_to_create": "Nutze diese Weiterleitungs-URL, um die OAuth-Anwendung zu erstellen oder zu aktualisieren. Gehe zu den {0} und richte die OAuth-Anwendung ein.", "developer_settings": "Entwicklereinstellungen", "public_url_for_oauth_if": "Öffentliche URL für OAuth, wenn unterschiedlich von URL ({0})", "forge_saved": "Plattform gespeichert", "oauth_redirect_url": "OAuth-Weiterleitungs-URL", "developer_settings_to_create": "Gehe zu den {0} und richte die OAuth-Anwendung ein.", "weblate": "unserem Weblate", "use_this_redirect_url_to_create": "Nutze diese Weiterleitungs-URL, um die OAuth-Anwendung zu erstellen oder zu aktualisieren.", "help_translating": "Du kannst auf {0} helfen, Woodpecker in deine Sprache zu übersetzen.", "extensions": "Erweiterungen", "extension_endpoint_placeholder": "z. B. https://example.com/api", "config_extension_endpoint": "Endpunkt der Konfigurations-Erweiterung", "extensions_description": "Erweiterungen sind HTTP-Dienste, die von Woodpecker anstelle der integrierten aufgerufen werden können.", "extensions_signatures_public_key": "Öffentlicher Schlüssel für Signaturen", "extensions_configuration_saved": "Konfiguration der Erweiterungen gespeichert", "extensions_signatures_public_key_description": "Dieser öffentliche Schlüssel sollte von deinen Erweiterungen verwendet werden, um Webhook-Aufrufe von Woodpecker zu verifizieren.", "disabled": "Deaktiviert", "config_extension_exclusive": "Exklusiv", "config_extension_exclusive_desc": "Wenn aktiviert, werden alle anderen Möglichkeiten zum Laden der Konfiguration, einschließlich der Code-Plattform, übersprungen.", "registry_extension_endpoint": "Endpunkt der Register-Erweiterung", "global_level_registry": "globales Register", "org_level_registry": "Organisationsregister", "secret_extension_endpoint": "Endpunkt der Geheimnis-Erweiterung", "secret_extension_netrc": "Netrc-Zugangsdaten senden", "secret_extension_netrc_desc": "Die Netrc-Zugangsdaten für die Code-Plattform an die Geheimnis-Erweiterung senden.", "extension_netrc": "Netrc-Zugangsdaten bereitstellen", "extension_netrc_desc": "Die Netrc-Zugangsdaten der Code-Plattform an die Erweiterung weitergeben." } ================================================ FILE: web/src/assets/locales/en.json ================================================ { "cancel": "Cancel", "login_to_woodpecker_with": "Login to Woodpecker with", "login": "Login", "repos": "Repos", "repositories": { "title": "Repositories", "all": { "title": "All repositories", "desc": "Repositories sorted by last pipeline creation" }, "last": { "title": "Last visited", "desc": "Most recently visited repositories sorted by access time" } }, "docs": "Docs", "api": "API", "logout": "Logout", "search": "Search…", "username": "Username", "password": "Password", "back": "Back", "unknown_error": "An unknown error occurred", "documentation_for": "Documentation for \"{topic}\"", "pipeline_feed": "Pipeline feed", "empty_list": "No {entity} found!", "not_found": { "not_found": "Whoa 404, either we broke something or you had a typing mishap :-/", "back_home": "Back to home" }, "errors": { "not_found": "Server could not find requested object" }, "time": { "not_started": "not started yet", "just_now": "just now" }, "repo": { "manual_pipeline": { "title": "Trigger a manual pipeline run", "trigger": "Run pipeline", "select_branch": "Select branch", "variables": { "delete": "Delete variable", "title": "Additional pipeline variables", "desc": "Specify additional variables to be used in your pipeline. Variables with the same name are overwritten.", "name": "Variable name", "value": "Variable value" }, "show_pipelines": "Show pipelines", "no_manual_workflows": "No matching workflows found. Make sure at least one workflow runs on the manual event." }, "deploy_pipeline": { "title": "Trigger a deployment for current pipeline #{pipelineId}", "enter_target": "Target environment for deployment", "enter_task": "Deployment task", "trigger": "Deploy", "variables": { "delete": "Delete variable", "title": "Additional pipeline variables", "desc": "Specify additional variables to be used in your pipeline. Variables with the same name are overwritten.", "name": "Variable name", "value": "Variable value" } }, "activity": "Activity", "branches": "Branches", "pull_requests": "Pull requests", "add": "Add repository", "user_none": "This organization/user has no projects yet", "not_allowed": "You are not allowed to access this repository", "enable": { "enable": "Enable", "enabled": "Already enabled", "disabled": "Disabled", "success": "Repository enabled", "new_forge_repo": "new repo on forge", "stale_wp_repo": "outdated Woodpecker repo", "conflict": "Conflict", "conflict_desc": "This repository was recreated on the forge with a new ID, but an outdated entry with the same name still exists in Woodpecker. Delete the outdated to enable the new one or Repair the old.", "forge_repo_missing": "Forge repo is missing!" }, "open_in_forge": "Open repository in forge", "visibility": { "visibility": "Project visibility", "public": { "public": "Public", "desc": "Anyone can see your project without being logged in." }, "private": { "private": "Private", "desc": "Only you and other owners of the repository can see this project." }, "internal": { "internal": "Internal", "desc": "Only authenticated users of the Woodpecker instance can see this project." } }, "settings": { "not_allowed": "You are not allowed to access the settings of this repository", "general": { "general": "Project", "project": "Project settings", "save": "Save settings", "success": "Project settings updated", "pipeline_path": { "path": "Pipeline path", "default": "By default: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Path to your pipeline config (for example {0}). Folders should end with a {1}.", "desc_path_example": "my/path/" }, "allow_pr": { "allow": "Allow Pull Requests", "desc": "Allow the execution of pipelines on pull requests." }, "allow_deploy": { "allow": "Allow Deployments", "desc": "Allow deployments for successful pipelines. All users with push permissions can trigger these, so use with caution." }, "netrc_only_trusted": { "netrc_only_trusted": "Custom trusted clone plugins", "desc": "Plugins that get access to netrc credentials that can be used to clone repositories from the forge or push them into the forge." }, "trusted": { "trusted": "Trusted", "network": { "network": "Network", "desc": "Pipeline containers get access to network privileges like changing DNS." }, "volumes": { "volumes": "Volumes", "desc": "Pipeline containers are allowed to mount volumes." }, "security": { "security": "Security", "desc": "Pipeline containers get access to security privileges." } }, "timeout": { "timeout": "Timeout", "minutes": "minutes" }, "cancel_prev": { "cancel": "Cancel previous pipelines", "desc": "Selected event triggers cancel pending and running pipelines of the same event before starting the next one." } }, "crons": { "crons": "Crons", "desc": "Cron jobs can be used to trigger pipelines on a regular basis.", "show": "Show crons", "add": "Add cron", "none": "There are no crons yet.", "save": "Save cron", "created": "Cron created", "saved": "Cron saved", "deleted": "Cron deleted", "next_exec": "Next execution", "not_executed_yet": "Not executed yet", "run": "Run now", "branch": { "title": "Branch", "placeholder": "Branch (uses default branch if empty)" }, "name": { "name": "Name", "placeholder": "Name of the cron job" }, "schedule": { "title": "Schedule (based on UTC)", "placeholder": "Schedule" }, "edit": "Edit cron", "delete": "Delete cron", "enabled": "Enabled" }, "badge": { "badge": "Badge", "type": "Syntax", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "branch": "Branch", "events": "Events", "workflow": "Workflow", "step": "Step" }, "actions": { "actions": "Actions", "repair": { "repair": "Repair repository", "success": "Repository repaired" }, "disable": { "disable": "Disable repository", "success": "Repository disabled" }, "enable": { "enable": "Enable repository", "success": "Repository enabled" }, "delete": { "delete": "Delete repository", "confirm": "All data will be lost after this action!\n\nDo you really want to proceed?", "success": "Repository deleted" } } }, "pipeline": { "tasks": "Tasks", "config": "Config", "files": "Changed files", "no_pipelines": "No pipelines have been started yet.", "load_more": "Load more", "no_pipeline_steps": "No pipeline steps available!", "step_not_started": "This step hasn't started yet.", "pipelines_for": "Pipelines for branch \"{branch}\"", "pipelines_for_pr": "Pipelines for pull request #{index}", "exit_code": "Exit Code {exitCode}", "loading": "Loading…", "no_logs": "No logs", "pipeline": "Pipeline #{pipelineId}", "log_title": "Step Logs", "log_download_error": "An error occurred while downloading the log file", "log_delete_confirm": "Do you really want to delete the step logs?", "log_delete_error": "An error occurred when deleting the step logs", "actions": { "cancel": "Cancel", "restart": "Restart", "canceled": "This step has been canceled.", "skipped": "This step has been skipped.", "cancel_success": "Pipeline canceled", "deploy": "Deploy", "restart_success": "Pipeline restarted", "log_download": "Download", "log_delete": "Delete", "log_auto_scroll": "Enable automatic scrolling", "log_auto_scroll_off": "Disable automatic scrolling", "expand_all": "Expand all", "collapse_all": "Collapse all" }, "protected": { "awaits": "This pipeline is awaiting approval from a maintainer!", "approve": "Approve", "decline": "Decline", "declined": "This pipeline has been declined!", "approve_success": "Pipeline approved", "decline_success": "Pipeline declined" }, "event": { "push": "Push", "tag": "Tag", "pr": "Pull Request", "pr_closed": "Pull Request merged/closed", "pr_metadata": "Pull Request metadata changed", "deploy": "Deploy", "cron": "Cron", "manual": "Manual", "release": "Release" }, "status": { "status": "Status: {status}", "blocked": "blocked", "pending": "pending", "running": "running", "started": "started", "skipped": "skipped", "canceled": "canceled", "success": "success", "declined": "declined", "error": "error", "failure": "failure", "killed": "killed" }, "errors": "Errors", "warnings": "Warnings", "show_errors": "Show errors", "we_got_some_errors": "Oh no, an error occurred!", "duration": "Pipeline duration: {duration}", "created": "Created: {created}", "version": "The Woodpecker version this pipeline was executed on.", "version_header": "Woodpecker version", "cancel_info": { "superseded_by": "Superseded by #{pipelineId}", "canceled_by_user": "Canceled by {user}", "canceled_by_step": "Canceled due to {step}" }, "debug": { "title": "Debug", "download_metadata": "Download metadata", "metadata_download_error": "Error downloading metadata", "metadata_download_successful": "Metadata downloaded successfully", "no_permission": "You are not allowed to access the debug information", "metadata_exec_title": "Re-run pipeline locally", "metadata_exec_desc": "Download the metadata of this pipeline to run it locally. This allows you to fix problems and test changes before committing them. The Woodpecker CLI must be installed locally in the same version as the server." }, "view": "View pipeline" } }, "org": { "settings": { "not_allowed": "You are not allowed to access the settings of this organization", "secrets": { "desc": "Organization secrets can be used in the pipelines of all repositories owned by the organization." }, "registries": { "desc": "Organization registry credentials can be added to use private images for all pipelines of an organization." }, "agents": { "desc": "Agents registered for this organization." } } }, "admin": { "settings": { "settings": "Admin Settings", "not_allowed": "You are not allowed to access server settings", "secrets": { "desc": "Global secrets can be used in the pipelines of all repositories.", "warning": "These secrets are available to all users." }, "registries": { "desc": "Global registry credentials can be added to use private images for all pipelines.", "warning": "These registry credentials are available to all users." }, "agents": { "agents": "Agents", "desc": "Agents registered on this server.", "none": "There are no agents yet.", "id": "ID", "add": "Add agent", "save": "Save agent", "show": "Show agents", "created": "Agent created", "saved": "Agent saved", "deleted": "Agent deleted", "name": { "name": "Name", "placeholder": "Name of the agent" }, "no_schedule": { "name": "Disable agent", "placeholder": "Stop agent from taking new tasks" }, "token": "Token", "platform": { "platform": "Platform", "badge": "platform" }, "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "capacity": "Capacity", "desc": "The maximum amount of parallel pipelines executed by this agent.", "badge": "capacity" }, "custom_labels": { "custom_labels": "Custom Labels", "desc": "The custom labels set by the agent admin on agent startup." }, "org": { "badge": "org" }, "version": "Version", "last_contact": { "last_contact": "Last contact", "badge": "last contact" }, "never": "Never", "delete_confirm": "Do you really want to delete this agent? It will no longer be able to connect to the server.", "edit_agent": "Edit agent", "delete_agent": "Delete agent" }, "queue": { "queue": "Queue", "desc": "Tasks waiting to be executed by agents.", "pause": "Pause", "resume": "Resume", "paused": "Queue is paused", "resumed": "Queue is resumed", "tasks": "Tasks", "task_running": "Task is running", "task_pending": "Task is pending", "task_waiting_on_deps": "Task is waiting on dependencies", "agent": "agent", "waiting_for": "waiting for", "stats": { "completed_count": "Completed Tasks", "worker_count": "Free", "running_count": "Running", "pending_count": "Pending", "waiting_on_deps_count": "Waiting on dependencies" } }, "users": { "users": "Users", "desc": "Users registered for this server.", "login": "Login", "email": "Email", "avatar_url": "Avatar URL", "save": "Save user", "cancel": "Cancel", "show": "Show users", "add": "Add user", "none": "There are no users yet.", "delete_confirm": "Do you really want to delete this user? This will also delete all repositories owned by this user.", "deleted": "User deleted", "created": "User created", "saved": "User saved", "admin": { "admin": "Admin", "placeholder": "User is an admin" }, "delete_user": "Delete user", "edit_user": "Edit user" }, "orgs": { "orgs": "Organizations", "desc": "Organizations owning repositories on this server.", "none": "There are no organizations yet.", "org_settings": "Organization settings", "delete_org": "Delete organization", "deleted": "Organization deleted", "delete_confirm": "Do you really want to delete this organization? This will also delete all repositories owned by this organization.", "view": "View organization" }, "repos": { "repos": "Repositories", "desc": "Repositories that are or were activated on this server.", "none": "There are no repositories yet.", "view": "View repository", "settings": "Repository settings", "disabled": "Disabled", "repair": { "repair": "Repair all", "success": "Repositories repaired" } } } }, "user": { "settings": { "settings": "User Settings", "general": { "general": "Account", "language": "Language", "theme": { "theme": "Theme", "light": "Light", "dark": "Dark", "auto": "Auto" } }, "secrets": { "desc": "User secrets can be used in the pipelines of all repositories owned by the user." }, "registries": { "desc": "User registry credentials can be added to use private images for all personal pipelines." }, "cli_and_api": { "cli_and_api": "CLI & API", "desc": "Personal Access Token, CLI and API usage", "token": "Personal Access Token", "api_usage": "Example API Usage", "cli_usage": "Example CLI Usage", "download_cli": "Download CLI", "reset_token": "Reset token", "swagger_ui": "Swagger UI" }, "agents": { "desc": "Agents registered to your account repos." } } }, "secrets": { "secrets": "Secrets", "desc": "Secrets can be used in all pipelines of this repository.", "none": "There are no secrets yet.", "add": "Add secret", "save": "Save secret", "show": "Show secrets", "name": "Name", "value": "Value", "note": "Note", "delete_confirm": "Do you really want to delete this secret?", "deleted": "Secret deleted", "created": "Secret created", "saved": "Secret saved", "plugins": { "images": "Available only for the following plugins", "desc": "List of plugin images where this secret is available. Leave empty to allow all plugins and normal steps." }, "events": { "events": "Available at the following events", "warning": "Exposing secrets to pull requests could allow bad actors to steal your secrets with a malicious pull request." }, "edit": "Edit secret", "delete": "Delete secret" }, "registries": { "registries": "Registries", "credentials": "Registry credentials", "desc": "Registry credentials can be added to use private images for pipelines.", "none": "There are no registry credentials yet.", "address": { "address": "Address", "desc": "Registry Address (e.g. docker.io)" }, "show": "Show registries", "save": "Save registry", "add": "Add registry", "view": "View registry", "edit": "Edit registry", "delete": "Delete registry", "delete_confirm": "Do you really want to delete this registry?", "created": "Registry credentials created", "saved": "Registry credentials saved", "deleted": "Registry credentials deleted" }, "default": "default", "info": "Info", "running_version": "You are running Woodpecker {0}", "update_woodpecker": "Please update your Woodpecker instance to {0}", "global_level_secret": "global secret", "org_level_secret": "organization secret", "global_level_registry": "global registry", "org_level_registry": "organization registry", "login_to_cli": "Login to CLI", "login_to_cli_description": "If you continue, you will be logged in to the CLI.", "abort": "Abort", "cli_login_success": "Login to CLI successful", "cli_login_failed": "Login to CLI failed", "cli_login_denied": "Login to CLI denied", "return_to_cli": "You can now close this tab and return to the CLI.", "settings": "Settings", "oauth_error": "Error while authenticating against OAuth provider", "internal_error": "Internal error occurred", "registration_closed": "The registration is closed", "access_denied": "You are not allowed to access this instance", "org_access_denied": "You are not allowed to access this organization", "invalid_state": "The OAuth state is invalid", "extensions": "Extensions", "extensions_description": "Extensions are HTTP services that can be called by Woodpecker instead of using the builtin ones.", "extension_endpoint_placeholder": "e.g. https://example.com/api", "config_extension_endpoint": "Config extension endpoint", "registry_extension_endpoint": "Registry extension endpoint", "config_extension_exclusive": "Exclusive", "config_extension_exclusive_desc": "If enabled, will skip all other ways of configuration fetching, including the forge.", "secret_extension_endpoint": "Secret extension endpoint", "extension_netrc": "Include netrc credentials", "extension_netrc_desc": "Send forge netrc credentials to the extension.", "extensions_signatures_public_key": "Public key for signatures", "extensions_signatures_public_key_description": "This public key should be used by your extensions to verify webhook calls from Woodpecker.", "extensions_configuration_saved": "Extensions configuration saved", "require_approval": { "desc": "Prevent malicious pipelines from exposing secrets or running harmful tasks by approving them before execution.", "require_approval_for": "Approval requirements", "none": "None", "none_desc": "Every event triggers pipelines, including pull requests. This setting can be dangerous and is only recommended for private instances.", "forks": "Pull request from forked repository", "pull_requests": "All pull requests", "all_events": "All events from forge", "allowed_users": { "allowed_users": "Allowed users", "desc": "Pipelines created by the listed users never require approval." } }, "no_search_results": "No results found", "forges": "Forges", "forges_desc": "Configure forges hosting repositories Woodpecker should run for.", "add_forge": "Add forge", "show_forges": "Show forges", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Addon", "forge_type": "Forge type", "oauth_client_id": "OAuth Client ID", "oauth_client_secret": "OAuth Client Secret", "oauth_host": "OAuth host", "merge_ref": "Merge ref", "merge_ref_desc": "Ref to use for merge base. This is used to determine the diff for pull requests.", "public_only": "Public only", "public_only_desc": "Only show public repositories.", "git_username": "Git username", "git_username_desc": "Username for the Git user.", "git_password": "Git password", "git_password_desc": "Password or personal access token for the Git user.", "executable": "Executable", "executable_desc": "Path to the addon executable.", "save": "Save", "add": "Add", "skip_verify": "Skip SSL verification", "skip_verify_desc": "Skip SSL verification for the API connection. This is not recommended for production use.", "url": "URL", "forge_managed_by_env": "The primary forge is managed by environment variables. Any changes to this forge will be reverted on a restart.", "oauth_redirect_url": "OAuth redirect URL", "forge_created": "Forge created", "advanced_options": "Advanced options", "leave_empty_to_keep_current_value": "Leave empty to keep current value", "forge_deleted": "Forge deleted", "forge_delete_confirm": "Do you really want to delete this forge? This will also delete all repositories, users and pipelines related to this forge.", "edit_forge": "Edit forge", "delete_forge": "Delete forge", "no_forges": "There are no forges yet.", "use_this_redirect_url_to_create": "Use this redirect URL to create or update the OAuth application.", "developer_settings_to_create": "Go to the {0} and set up the OAuth application.", "developer_settings": "developer settings", "public_url_for_oauth_if": "Public URL for OAuth if different from URL ({0})", "forge_saved": "Forge saved", "fullscreen": "Fullscreen", "exit_fullscreen": "Exit fullscreen", "help_translating": "You can help translate Woodpecker into your language on {0}.", "weblate": "our Weblate", "disabled": "Disabled" } ================================================ FILE: web/src/assets/locales/eo.json ================================================ { "repos": "Repozitorioj", "search": "Serĉi…", "unknown_error": "Nekonata eraro okazis", "time": { "not_started": "ankoraŭ ne komencita", "just_now": "ĵus nun" }, "repo": { "manual_pipeline": { "title": "Ekigi permanan pipeline-rulon", "trigger": "Ruli pipeline-on", "select_branch": "Elekti branĉon", "variables": { "title": "Kromaj pipeline-variabloj", "desc": "Specifi kromajn variablojn por uzi en via pipeline. Variabloj kun sama nomo estas anstataŭigitaj.", "name": "Variabla nomo", "value": "Variabla valoro", "delete": "Forigi variablon" }, "show_pipelines": "Montri pipeline-ojn" }, "deploy_pipeline": { "title": "Ekigi deployment-on por nuna pipeline #{pipelineId}", "enter_target": "Celmedion por deployment", "trigger": "Deploy", "variables": { "title": "Kromaj pipeline-variabloj", "desc": "Specifi kromajn variablojn por uzi en via pipeline. Variabloj kun sama nomo estas anstataŭigitaj.", "name": "Variabla nomo", "value": "Variabla valoro", "delete": "Forigi variablon" }, "enter_task": "Deployment-tasko" }, "activity": "Agado", "branches": "Branĉoj", "add": "Aldoni repozitorion", "enable": { "enable": "Ŝalti", "enabled": "Jam ŝaltita", "success": "Repozitorio ŝaltita", "disabled": "Malŝaltita" }, "open_in_forge": "Malfermi repozitorion en Forĝo", "settings": { "not_allowed": "Vi ne rajtas aliri la agordojn de ĉi tiu repozitorio", "general": { "general": "Projekto", "project": "Projektaj agordoj", "pipeline_path": { "path": "Pipeline-vojo", "default": "Defaŭlte: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Vojo al via pipeline-agordo (ekzemple {0}). Dosierujoj devas finiĝi per {1}.", "desc_path_example": "mia/vojo/" }, "allow_pr": { "allow": "Permesi Pull Request-ojn", "desc": "Permesi la plenumigon de pipeline-oj por pull request-oj." }, "trusted": { "trusted": "Fidata", "network": { "network": "Reto", "desc": "Pipeline-ujoj ricevas aliron al retprivilegioj kiel ŝanĝi DNS." }, "volumes": { "volumes": "Volumoj", "desc": "Pipeline-ujoj rajtas munti volumojn." }, "security": { "security": "Sekureco", "desc": "Pipeline-ujoj ricevas aliron al sekurecprivilegioj." } }, "timeout": { "timeout": "Tempolimo", "minutes": "minutoj" }, "cancel_prev": { "cancel": "Nuligi antaŭajn pipeline-ojn", "desc": "Elektitaj eventoj nuligas atendantajn kaj rulantajn pipeline-ojn de la sama evento antaŭ ol komenci la sekvan." }, "save": "Konservi agordojn", "success": "Projektaj agordoj ĝisdatigitaj", "allow_deploy": { "allow": "Permesi Deployment-ojn", "desc": "Permesi deployment-ojn por sukcesaj pipeline-oj. Ĉiuj uzantoj kun push-permesoj povas ekigi ilin, do uzu zorgeme." }, "netrc_only_trusted": { "netrc_only_trusted": "Propraj fidataj klon-plugin-oj", "desc": "Plugin-oj kiuj ricevas aliron al netrc-legitimaĵoj kiuj povas esti uzataj por kloni repozitoriojn el la Forĝo aŭ push-i ilin al la Forĝo." } }, "badge": { "badge": "Insigno", "type": "Sintakso", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "branch": "Branĉo" }, "actions": { "actions": "Agoj", "repair": { "repair": "Ripari repozitorion", "success": "Repozitorio riparita" }, "disable": { "disable": "Malŝalti repozitorion", "success": "Repozitorio malŝaltita" }, "delete": { "delete": "Forigi repozitorion", "confirm": "Ĉiuj datenoj perdiĝos post ĉi tiu ago!\n\nĈu vi vere volas daŭrigi?", "success": "Repozitorio forigita" }, "enable": { "enable": "Ŝalti repozitorion", "success": "Repozitorio ŝaltita" } }, "crons": { "crons": "Cron-oj", "desc": "Cron-taskoj povas esti uzataj por ekigi pipeline-ojn regule.", "show": "Montri cron-ojn", "add": "Aldoni cron-on", "none": "Ankoraŭ estas neniuj cron-oj.", "save": "Konservi cron-on", "created": "Cron-o kreita", "saved": "Cron-o konservita", "deleted": "Cron-o forigita", "next_exec": "Sekva plenumo", "not_executed_yet": "Ankoraŭ ne plenumita", "run": "Ruli nun", "branch": { "title": "Branĉo", "placeholder": "Branĉo (uzas defaŭltan branĉon se malplena)" }, "name": { "name": "Nomo", "placeholder": "Nomo de la cron-tasko" }, "schedule": { "title": "Plano (bazita sur UTC)", "placeholder": "Plano" }, "edit": "Redakti cron-on", "delete": "Forigi cron-on" } }, "pipeline": { "config": "Agordo", "files": "Ŝanĝitaj dosieroj", "no_pipelines": "Neniuj pipeline-oj ankoraŭ komenciĝis.", "no_pipeline_steps": "Neniuj pipeline-stepoj disponeblaj!", "step_not_started": "Ĉi tiu stepo ankoraŭ ne komenciĝis.", "loading": "Ŝarĝante…", "actions": { "cancel": "Nuligi", "restart": "Rekomenci", "canceled": "Ĉi tiu stepo estis nuligita.", "cancel_success": "Pipeline nuligita", "deploy": "Deploy", "restart_success": "Pipeline rekomencita", "log_download": "Elŝuti", "log_delete": "Forigi", "log_auto_scroll": "Ŝalti aŭtomatan rulumigon", "log_auto_scroll_off": "Malŝalti aŭtomatan rulumigon" }, "protected": { "awaits": "Ĉi tiu pipeline atendas aprobon de prizorganto!", "approve": "Aprobi", "decline": "Rifuzi", "declined": "Ĉi tiu pipeline estis rifuzita!", "approve_success": "Pipeline aprobita", "decline_success": "Pipeline rifuzita" }, "status": { "status": "Stato: {status}", "blocked": "blokita", "pending": "atendanta", "running": "rulanta", "started": "komencita", "skipped": "saltita", "success": "sukceso", "declined": "rifuzita", "error": "eraro", "failure": "malsukceso", "killed": "mortigita" }, "we_got_some_errors": "Ho ve, eraro okazis!", "created": "Kreita: {created}", "tasks": "Taskoj", "pipelines_for": "Pipeline-oj por branĉo \"{branch}\"", "pipelines_for_pr": "Pipeline-oj por Pull Request #{index}", "exit_code": "Elirkodo {exitCode}", "no_logs": "Neniuj protokoloj", "pipeline": "Pipeline #{pipelineId}", "log_title": "Stepaj protokoloj", "log_download_error": "Eraro okazis dum elŝuti la protokoldosieron", "log_delete_confirm": "Ĉu vi vere volas forigi la stepajn protokolojn?", "log_delete_error": "Eraro okazis dum forigi la stepajn protokolojn", "event": { "push": "Push", "tag": "Tag", "pr": "Pull Request", "pr_closed": "Pull Request kunfandita/fermita", "pr_metadata": "Pull Request-metadatenoj ŝanĝitaj", "deploy": "Deploy", "cron": "Cron", "manual": "Permana", "release": "Release" }, "errors": "Eraroj", "warnings": "Avertoj", "show_errors": "Montri erarojn", "duration": "Pipeline-daŭro: {duration}", "debug": { "title": "Senerarigi", "download_metadata": "Elŝuti metadatenojn", "metadata_download_error": "Eraro dum elŝuti metadatenojn", "metadata_download_successful": "Metadatenoj sukcese elŝutitaj", "no_permission": "Vi ne rajtas aliri la senerarigajn informojn", "metadata_exec_title": "Reruli pipeline-on loke", "metadata_exec_desc": "Elŝuti la metadatenojn de ĉi tiu pipeline por ruli ĝin loke. Ĉi tio permesas fiksi problemojn kaj testi ŝanĝojn antaŭ ol commit-i ilin. La Woodpecker CLI devas esti instalita loke en la sama versio kiel la servilo." }, "view": "Vidi pipeline-on" }, "pull_requests": "Pull Request-oj", "user_none": "Ĉi tiu organizo/uzanto ankoraŭ havas neniujn projektojn", "not_allowed": "Vi ne rajtas aliri ĉi tiun repozitorion", "visibility": { "visibility": "Projekta videbleco", "public": { "public": "Publika", "desc": "Iu ajn povas vidi vian projekton sen ensaluti." }, "private": { "private": "Privata", "desc": "Nur vi kaj aliaj posedantoj de la repozitorio povas vidi ĉi tiun projekton." }, "internal": { "internal": "Interna", "desc": "Nur aŭtentikigitaj uzantoj de la Woodpecker-instanco povas vidi ĉi tiun projekton." } } }, "org": { "settings": { "secrets": { "desc": "Organizaj sekretoj povas esti uzataj en la pipeline-oj de ĉiuj repozitorioj poseditaj de la organizo." }, "not_allowed": "Vi ne rajtas aliri la agordojn de ĉi tiu organizo", "registries": { "desc": "Organizaj registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por ĉiuj pipeline-oj de organizo." }, "agents": { "desc": "Agentoj registritaj por ĉi tiu organizo." } } }, "admin": { "settings": { "settings": "Administraj agordoj", "secrets": { "desc": "Tutmondaj sekretoj povas esti uzataj en la pipeline-oj de ĉiuj repozitorioj.", "warning": "Ĉi tiuj sekretoj estas disponeblaj por ĉiuj uzantoj." }, "agents": { "agents": "Agentoj", "desc": "Agentoj registritaj sur ĉi tiu servilo.", "none": "Ankoraŭ estas neniuj agentoj.", "id": "ID", "created": "Agento kreita", "version": "Versio", "never": "Neniam", "delete_confirm": "Ĉu vi vere volas forigi ĉi tiun agenton? Ĝi ne plu povos konektiĝi al la servilo.", "delete_agent": "Forigi agenton", "add": "Aldoni agenton", "save": "Konservi agenton", "show": "Montri agentojn", "saved": "Agento konservita", "deleted": "Agento forigita", "name": { "name": "Nomo", "placeholder": "Nomo de la agento" }, "no_schedule": { "name": "Malŝalti agenton", "placeholder": "Haltigi agenton de preni novajn taskojn" }, "token": "Ĵetono", "platform": { "platform": "Platformo", "badge": "platformo" }, "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "capacity": "Kapacito", "desc": "La maksimuma kvanto de paralelaj pipeline-oj plenumitaj de ĉi tiu agento.", "badge": "kapacito" }, "custom_labels": { "custom_labels": "Propraj etikedoj", "desc": "La propraj etikedoj agordita de la agenta administranto ĉe agenta starto." }, "org": { "badge": "organizo" }, "last_contact": { "last_contact": "Lasta kontakto", "badge": "lasta kontakto" }, "edit_agent": "Redakti agenton" }, "queue": { "queue": "Vicumo", "desc": "Taskoj atendantaj plenumigon de agentoj.", "agent": "agento", "waiting_for": "atendante", "pause": "Paŭzigi", "resume": "Daŭrigi", "paused": "Vicumo estas paŭzigita", "resumed": "Vicumo estas daŭrigita", "tasks": "Taskoj", "task_running": "Tasko rulas", "task_pending": "Tasko atendas", "task_waiting_on_deps": "Tasko atendas dependecojn", "stats": { "completed_count": "Finitaj taskoj", "worker_count": "Libera", "running_count": "Rulanta", "pending_count": "Atendanta", "waiting_on_deps_count": "Atendante dependecojn" } }, "users": { "users": "Uzantoj", "desc": "Uzantoj registritaj por ĉi tiu servilo.", "login": "Salutnomo", "email": "Retpoŝto", "avatar_url": "Avatar-URL", "save": "Konservi uzanton", "cancel": "Nuligi", "add": "Aldoni uzanton", "none": "Ankoraŭ estas neniuj uzantoj.", "delete_confirm": "Ĉu vi vere volas forigi ĉi tiun uzanton? Ĉi tio ankaŭ forigos ĉiujn repozitoriojn poseditajn de ĉi tiu uzanto.", "deleted": "Uzanto forigita", "created": "Uzanto kreita", "saved": "Uzanto konservita", "delete_user": "Forigi uzanton", "show": "Montri uzantojn", "admin": { "admin": "Admin", "placeholder": "Uzanto estas administranto" }, "edit_user": "Redakti uzanton" }, "orgs": { "orgs": "Organizoj", "view": "Vidi organizon", "desc": "Organizoj posedantaj repozitoriojn sur ĉi tiu servilo.", "none": "Ankoraŭ estas neniuj organizoj.", "org_settings": "Organizaj agordoj", "delete_org": "Forigi organizon", "deleted": "Organizo forigita", "delete_confirm": "Ĉu vi vere volas forigi ĉi tiun organizon? Ĉi tio ankaŭ forigos ĉiujn repozitoriojn poseditajn de ĉi tiu organizo." }, "repos": { "repos": "Repozitorioj", "desc": "Repozitorioj kiuj estas aŭ estis aktivigitaj sur ĉi tiu servilo.", "none": "Ankoraŭ estas neniuj repozitorioj.", "disabled": "Malŝaltita", "view": "Vidi repozitorion", "settings": "Repozitoriaj agordoj", "repair": { "repair": "Ripari ĉiujn", "success": "Repozitorioj riparitaj" } }, "not_allowed": "Vi ne rajtas aliri la servilajn agordojn", "registries": { "desc": "Tutmondaj registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por ĉiuj pipeline-oj.", "warning": "Ĉi tiuj registrejaj legitimaĵoj estas disponeblaj por ĉiuj uzantoj." } } }, "user": { "settings": { "settings": "Uzantaj agordoj", "general": { "general": "Konto", "language": "Lingvo", "theme": { "theme": "Etoso", "light": "Hela", "dark": "Malhela", "auto": "Aŭtomata" } }, "secrets": { "desc": "Uzantaj sekretoj povas esti uzataj en la pipeline-oj de ĉiuj repozitorioj poseditaj de la uzanto." }, "registries": { "desc": "Uzantaj registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por ĉiuj personaj pipeline-oj." }, "cli_and_api": { "cli_and_api": "CLI & API", "desc": "Persona Alir-Ĵetono, CLI kaj API-uzo", "token": "Persona Alir-Ĵetono", "api_usage": "Ekzempla API-uzo", "cli_usage": "Ekzempla CLI-uzo", "download_cli": "Elŝuti CLI", "reset_token": "Reagordi ĵetonon", "swagger_ui": "Swagger UI" }, "agents": { "desc": "Agentoj registritaj al viaj kontaj repozitorioj." } } }, "url": "URL", "cancel": "Nuligi", "login_to_woodpecker_with": "Ensaluti al Woodpecker per", "login": "Ensaluti", "repositories": { "title": "Repozitorioj", "all": { "title": "Ĉiuj repozitorioj", "desc": "Repozitorioj ordigitaj laŭ lasta pipeline-kreo" }, "last": { "title": "Laste vizititaj", "desc": "Plej freŝdate vizititaj repozitorioj ordigitaj laŭ alirtempo" } }, "docs": "Dokumentaro", "api": "API", "logout": "Elsaluti", "username": "Uzantonomo", "password": "Pasvorto", "back": "Reen", "documentation_for": "Dokumentaro por \"{topic}\"", "pipeline_feed": "Pipeline-fluo", "empty_list": "Neniuj {entity} troviĝis!", "not_found": { "not_found": "Hu, 404, aŭ ni ion rompis aŭ vi mistajpis :-/", "back_home": "Reen al hejmo" }, "errors": { "not_found": "Servilo ne povis trovi petitan objekton" }, "secrets": { "secrets": "Sekretoj", "desc": "Sekretoj povas esti uzataj en ĉiuj pipeline-oj de ĉi tiu repozitorio.", "none": "Ankoraŭ estas neniuj sekretoj.", "add": "Aldoni sekreton", "save": "Konservi sekreton", "show": "Montri sekretojn", "name": "Nomo", "value": "Valoro", "delete_confirm": "Ĉu vi vere volas forigi ĉi tiun sekreton?", "deleted": "Sekreto forigita", "created": "Sekreto kreita", "saved": "Sekreto konservita", "plugins": { "images": "Disponebla nur por la sekvaj plugin-oj", "desc": "Listo de plugin-bildoj kie ĉi tiu sekreto estas disponebla. Lasu malplena por permesi ĉiujn plugin-ojn kaj normalajn stepojn." }, "events": { "events": "Disponeblaj ĉe la sekvaj eventoj", "warning": "Malkaŝi sekretojn al pull request-oj povus permesi malicajn agantojn ŝteli viajn sekretojn per malica pull request." }, "edit": "Redakti sekreton", "delete": "Forigi sekreton" }, "registries": { "registries": "Registrejoj", "credentials": "Registrejaj legitimaĵoj", "desc": "Registrejaj legitimaĵoj povas esti aldonitaj por uzi privatajn bildojn por pipeline-oj.", "none": "Ankoraŭ estas neniuj registrejaj legitimaĵoj.", "address": { "address": "Adreso", "desc": "Registreja adreso (ekz. docker.io)" }, "show": "Montri registrejojn", "save": "Konservi registrejon", "add": "Aldoni registrejon", "view": "Vidi registrejon", "edit": "Redakti registrejon", "delete": "Forigi registrejon", "delete_confirm": "Ĉu vi vere volas forigi ĉi tiun registrejon?", "created": "Registrejaj legitimaĵoj kreitaj", "saved": "Registrejaj legitimaĵoj konservitaj", "deleted": "Registrejaj legitimaĵoj forigitaj" }, "default": "defaŭlta", "info": "Info", "running_version": "Vi uzas Woodpecker {0}", "update_woodpecker": "Bonvolu ĝisdatigi vian Woodpecker-instancon al {0}", "global_level_secret": "tutmonda sekreto", "org_level_secret": "organiza sekreto", "login_to_cli": "Ensaluti al CLI", "login_to_cli_description": "Se vi daŭrigas, vi ensalutos al la CLI.", "abort": "Ĉesigi", "cli_login_success": "Ensaluto al CLI sukcesis", "cli_login_failed": "Ensaluto al CLI malsukcesis", "cli_login_denied": "Ensaluto al CLI rifuzita", "return_to_cli": "Vi nun povas fermi ĉi tiun langeton kaj reveni al la CLI.", "settings": "Agordoj", "oauth_error": "Eraro dum aŭtentikigi kontraŭ OAuth-provizanto", "internal_error": "Interna eraro okazis", "registration_closed": "La registriĝo estas fermita", "access_denied": "Vi ne rajtas aliri ĉi tiun instancon", "org_access_denied": "Vi ne rajtas aliri ĉi tiun organizon", "invalid_state": "La OAuth-stato estas nevalida", "extensions": "Etendaĵoj", "extensions_description": "Etendaĵoj estas HTTP-servoj kiuj povas esti vokitaj de Woodpecker anstataŭ uzi la integritajn.", "extension_endpoint_placeholder": "ekz. https://example.com/api", "config_extension_endpoint": "Agordo-etendaĵa finpunkto", "extensions_signatures_public_key": "Publika ŝlosilo por subskriboj", "extensions_signatures_public_key_description": "Ĉi tiu publika ŝlosilo devus esti uzata de viaj etendaĵoj por verifi webhook-vokojn el Woodpecker.", "extensions_configuration_saved": "Etendaĵa agordo konservita", "require_approval": { "desc": "Malhelpi malicajn pipeline-ojn malkaŝi sekretojn aŭ ruli malutilajn taskojn per aprobi ilin antaŭ plenumo.", "require_approval_for": "Aprob-postuloj", "none": "Neniu", "none_desc": "Ĉiu evento ekigas pipeline-ojn, inkluzive de pull request-oj. Ĉi tiu agordo povas esti danĝera kaj estas nur rekomendita por privataj instancoj.", "forks": "Pull Request el forkita repozitorio", "pull_requests": "Ĉiuj Pull Request-oj", "all_events": "Ĉiuj eventoj el Forĝo", "allowed_users": { "allowed_users": "Permesitaj uzantoj", "desc": "Pipeline-oj kreitaj de la listigitaj uzantoj neniam bezonas aprobon." } }, "no_search_results": "Neniuj rezultoj troviĝis", "forges": "Forĝoj", "forges_desc": "Agordi Forĝojn gastigantajn repozitoriojn por kiuj Woodpecker devus ruli.", "add_forge": "Aldoni Forĝon", "show_forges": "Montri Forĝojn", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Kromprogramo", "forge_type": "Forĝa tipo", "oauth_client_id": "OAuth-Klienta ID", "oauth_client_secret": "OAuth-Klienta sekreto", "oauth_host": "OAuth-gastiganto", "merge_ref": "Kunfanda ref", "merge_ref_desc": "Ref uzota por kunfanda bazo. Ĉi tio estas uzata por determini la diferon por pull request-oj.", "public_only": "Nur publikaj", "public_only_desc": "Nur montri publikajn repozitoriojn.", "git_username": "Git-uzantonomo", "git_username_desc": "Uzantonomo por la Git-uzanto.", "git_password": "Git-pasvorto", "git_password_desc": "Pasvorto aŭ persona alir-ĵetono por la Git-uzanto.", "executable": "Plenumebla", "executable_desc": "Vojo al la kromprograma plenumebla dosiero.", "save": "Konservi", "add": "Aldoni", "skip_verify": "Saltigi SSL-kontrolon", "skip_verify_desc": "Saltigi SSL-kontrolon por la API-konekto. Ĉi tio ne estas rekomendita por produkta uzo.", "forge_managed_by_env": "La ĉefa Forĝo estas administrata per mediaj variabloj. Ĉiuj ŝanĝoj al ĉi tiu Forĝo estos malfaritaj ĉe rekomencigo.", "oauth_redirect_url": "OAuth-alidirekta URL", "forge_created": "Forĝo kreita", "advanced_options": "Altnivelajn opciojn", "leave_empty_to_keep_current_value": "Lasu malplena por konservi nunan valoron", "forge_deleted": "Forĝo forigita", "forge_delete_confirm": "Ĉu vi vere volas forigi ĉi tiun Forĝon? Ĉi tio ankaŭ forigos ĉiujn repozitoriojn, uzantojn kaj pipeline-ojn rilatajn al ĉi tiu Forĝo.", "edit_forge": "Redakti Forĝon", "delete_forge": "Forigi Forĝon", "no_forges": "Ankoraŭ estas neniuj Forĝoj.", "use_this_redirect_url_to_create": "Uzu ĉi tiun alidirekt-URL por krei aŭ ĝisdatigi la OAuth-aplikaĵon.", "developer_settings_to_create": "Iru al {0} kaj agordu la OAuth-aplikaĵon.", "developer_settings": "evoluigistaj agordoj", "public_url_for_oauth_if": "Publika URL por OAuth se malsama ol URL ({0})", "forge_saved": "Forĝo konservita", "fullscreen": "Plenekrano", "exit_fullscreen": "Eliri plenekranon", "help_translating": "Vi povas helpi traduki Woodpecker en vian lingvon ĉe {0}.", "weblate": "nia Weblate", "disabled": "Malŝaltita" } ================================================ FILE: web/src/assets/locales/es.json ================================================ { "admin": { "settings": { "agents": { "add": "Añadir agente", "agents": "Agentes", "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "badge": "capacidad", "capacity": "Capacidad", "desc": "La cantidad máxima de pipelines paralelos ejecutados por este agente." }, "created": "Agente creado", "delete_agent": "Eliminar agente", "delete_confirm": "¿Realmente quieres borrar este agente? Ya no podrá conectarse al servidor.", "deleted": "Agente eliminado", "desc": "Agentes registrados en este servidor", "edit_agent": "Editar agente", "id": "ID", "last_contact": "Último contacto", "name": { "name": "Nombre", "placeholder": "Nombre del agente" }, "never": "Nunca", "no_schedule": { "name": "Desactivar agente", "placeholder": "Impedir que el agente acepte nuevas tareas" }, "none": "Aún no hay agentes.", "platform": { "badge": "plataforma", "platform": "Plataforma" }, "save": "Guardar agente", "saved": "Agente guardado", "show": "Mostrar agentes", "token": "Token", "version": "Versión" }, "not_allowed": "No puede acceder a la configuración del servidor", "orgs": { "delete_confirm": "¿Realmente desea eliminar esta organización? Esto también eliminará todos los repositorios de esta organización.", "delete_org": "Eliminar organización", "deleted": "Organización eliminada", "desc": "Organizaciones propietarias de repositorios en este servidor", "none": "Aún no hay organizaciones.", "org_settings": "Configuración de la organización", "orgs": "Organizaciones", "view": "Ver organización" }, "queue": { "agent": "agente", "desc": "Tareas en espera de ejecución por los agentes", "pause": "Pausa", "paused": "La cola está en pausa", "queue": "Cola", "resume": "Continuar", "resumed": "Se reanuda la cola", "stats": { "completed_count": "Tareas completadas", "pending_count": "Pendiente", "running_count": "Ejecutando", "waiting_on_deps_count": "A la espera de dependencias", "worker_count": "Libre" }, "task_pending": "Tarea pendiente", "task_running": "Tarea en ejecución", "task_waiting_on_deps": "Tarea en espera de dependencias", "tasks": "Tareas", "waiting_for": "a la espera de" }, "repos": { "desc": "Repositorios que están o estaban activados en este servidor", "disabled": "Desactivado", "none": "Aún no hay repositorios.", "repair": { "repair": "Reparar todos", "success": "Repositorios reparados" }, "repos": "Repositorios", "settings": "Configuración del repositorio", "view": "Ver repositorio" }, "secrets": { "add": "Añadir secreto", "created": "Secreto global creado", "deleted": "Secreto global eliminado", "desc": "Los secretos globales pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales del pipeline de cualquier repositorio.", "events": { "events": "Disponible en los siguientes eventos", "pr_warning": "Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos." }, "images": { "desc": "Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes", "images": "Disponible para las siguientes imágenes" }, "name": "Nombre", "none": "Aún no hay secretos globales.", "plugins_only": "Sólo disponible para plugins", "save": "Guardar secreto", "saved": "Secreto global guardado", "secrets": "Secretos", "show": "Mostrar secretos", "value": "Valor", "warning": "Estos secretos estarán disponibles para todos los usuarios del servidor." }, "settings": "Configuración", "users": { "add": "Añadir usuario", "admin": { "admin": "Admin", "placeholder": "El usuario es un administrador" }, "avatar_url": "URL avatar", "cancel": "Cancelar", "created": "Usuario creado", "delete_confirm": "¿Realmente desea eliminar este usuario? Esto también eliminará todos los repositorios de este usuario.", "delete_user": "Eliminar usuario", "deleted": "Usuario eliminado", "desc": "Usuarios registrados en este servidor", "edit_user": "Editar usuario", "email": "Email", "login": "Iniciar sesión", "none": "Aún no hay usuarios.", "save": "Guardar usuario", "saved": "Usuario guardado", "show": "Mostrar usuarios", "users": "Usuarios" } } }, "api": "API", "back": "Atrás", "cancel": "Cancelar", "default": "por defecto", "docs": "Documentación", "documentation_for": "Documentación de \"{topic}\"", "errors": { "not_found": "El servidor no ha podido encontrar el objeto solicitado" }, "info": "Info", "login": "Iniciar sesión", "logout": "Cerrar sesión", "not_found": { "back_home": "Volver a la página principal", "not_found": "404, ya sea rompimos algo o la dirección es incorrecta :-/" }, "org": { "settings": { "not_allowed": "No tiene permiso para acceder a los ajustes de esta organización", "secrets": { "add": "Añadir secreto", "created": "Secreto de organización creado", "deleted": "Secreto de organización eliminado", "desc": "Los secretos de la organización pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales de cualquier pipeline de la organización.", "events": { "events": "Disponible en los siguientes eventos", "pr_warning": "Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos." }, "images": { "desc": "Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes", "images": "Disponible para las siguientes imágenes" }, "name": "Nombre", "none": "Aún no hay secretos de organización.", "plugins_only": "Sólo disponible para plugins", "save": "Guardar secreto", "saved": "Secreto de organización guardado", "secrets": "Secretos", "show": "Mostrar secretos", "value": "Valor" }, "settings": "Configuración" } }, "password": "Contraseña", "pipeline_feed": "Reporte de actividad de Pipeline", "repo": { "activity": "Actividad", "add": "Añadir repositorio", "branches": "Ramas", "deploy_pipeline": { "enter_target": "Entorno de despliegue de destino", "title": "Iniciar despliegue para el pipeline actual #{pipelineId}", "trigger": "Despliegue", "variables": { "add": "Añadir variable", "desc": "Especifique variables adicionales para utilizar en su pipeline. Las variables con el mismo nombre se sobrescribirán.", "name": "Nombre de la variable", "title": "Variables adicionales del pipeline", "value": "Valor de la variable", "delete": "Eliminar variable" }, "enter_task": "Tarea de despliegue" }, "enable": { "disabled": "Desactivado", "enable": "Activar", "enabled": "Ya está activado", "list_reloaded": "Lista de repositorios actualizada", "reload": "Actualizar repositorios", "success": "Repositorio activado" }, "manual_pipeline": { "select_branch": "Escoger rama", "title": "Iniciar un pipeline manual", "trigger": "Corre el pipeline", "variables": { "add": "Añadir variable", "desc": "Especifique variables adiciónales para usar en su pipeline. Las variables con el mismo nombre se sobrescribirán.", "name": "Nombre de la variable", "title": "Variables adicionales del pipeline", "value": "Valor de la variable", "delete": "Eliminar variable" }, "show_pipelines": "Mostrar las pipelines" }, "not_allowed": "No tienes acceso a este repositorio", "open_in_forge": "Abrir repositorio en la forja", "pipeline": { "actions": { "cancel": "Cancelar", "cancel_success": "Pipeline cancelado", "canceled": "Este paso ha sido cancelado.", "deploy": "Despliegue", "log_auto_scroll": "Desplazarse automáticamente hacia abajo", "log_auto_scroll_off": "Desactivar el desplazamiento automático", "log_download": "Descargar", "restart": "Reiniciar", "restart_success": "Pipeline reiniciado" }, "config": "Config", "errors": "Errores ({count})", "event": { "cron": "Cron", "deploy": "Despliegue", "manual": "Manual", "pr": "Pull Request", "push": "Push", "tag": "Tag", "release": "Release" }, "exit_code": "Código de salida {exitCode}", "files": "Archivos modificados ({files})", "loading": "Cargando…", "log_download_error": "Se ha producido un error al descargar el archivo de registro", "log_title": "Registros de pasos", "no_files": "No se ha modificado ningún archivo.", "no_pipeline_steps": "¡No hay pasos de pipeline disponibles!", "no_pipelines": "Aún no se ha lanzado ningún pipeline.", "pipeline": "Pipeline #{pipelineId}", "pipelines_for": "Pipelines para la rama \"{branch}\"", "pipelines_for_pr": "Pipelines para pull request #{index}", "protected": { "approve": "Aprobar", "approve_success": "Pipeline aprobado", "awaits": "¡Este pipeline está a la espera de la aprobación de un mantenedor!", "decline": "Rechazar", "decline_success": "Pipeline rechazado", "declined": "¡Este pipeline ha sido rechazado!", "review": "Revisar cambios" }, "show_errors": "Mostrar errores", "status": { "blocked": "bloqueado", "declined": "rechazado", "error": "error", "failure": "fallo", "killed": "terminado", "pending": "pendiente", "running": "ejecutando", "skipped": "omitido", "started": "iniciado", "status": "Estado: {status}", "success": "éxito" }, "step_not_started": "Este paso aún no se ha iniciado.", "tasks": "Tareas", "warnings": "Avisos ({count})", "we_got_some_errors": "¡Oh no, tenemos algunos errores!" }, "pull_requests": "Pull Request", "settings": { "actions": { "actions": "Acciones", "delete": { "confirm": "¡¡¡Todos los datos se perderán después de esta acción!!!\n\n¿Realmente quieres proceder?", "delete": "Eliminar repositorio", "success": "Repositorio eliminado" }, "disable": { "disable": "Desactivar repositorio", "success": "Repositorio desactivado" }, "enable": { "enable": "Activar repositorio", "success": "Repositorio activado" }, "repair": { "repair": "Reparar repositorio", "success": "Repositorio reparado" } }, "badge": { "badge": "Placa", "branch": "Rama", "type": "Sintaxis", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL" }, "crons": { "add": "Añadir cron", "branch": { "placeholder": "Rama (utiliza la rama por defecto si está vacía)", "title": "Rama" }, "created": "Cron creado", "crons": "Crons", "delete": "Borrar cron", "deleted": "Cron borrado", "desc": "Las tareas Cron pueden utilizarse para activar pipelines de forma regular.", "edit": "Editar cron", "name": { "name": "Nombre", "placeholder": "Nombre de la tarea cron" }, "next_exec": "Siguiente ejecución", "none": "Aún no hay crons.", "not_executed_yet": "No se ha ejecutado todavía", "run": "Ejecutar ahora", "save": "Guardar cron", "saved": "Cron guardado", "schedule": { "placeholder": "Programación", "title": "Programación (basado en UTC)" }, "show": "Mostrar crons" }, "general": { "allow_pr": { "allow": "Permitir solicitudes de cambios", "desc": "Pipelines pueden ejecutar en las solicitudes de cambios." }, "cancel_prev": { "cancel": "Anular pipelines anteriores", "desc": "Permite cancelar los pipelines pendientes y en ejecución del mismo evento y contexto antes de iniciar el recién lanzado." }, "general": "General", "netrc_only_trusted": { "desc": "Sólo inyectar credenciales netrc en contenedores de confianza (recomendado).", "netrc_only_trusted": "Sólo inyectar credenciales netrc en contenedores de confianza" }, "pipeline_path": { "default": "Por defecto: .woodpecker/*.yml -> .woodpecker.yml", "desc": "Ruta a la configuración de su pipeline (por ejemplo {0}). Las carpetas deben terminar en {1}.", "desc_path_example": "mi/directorie/", "path": "Pasos del pipeline" }, "project": "Configuración del proyecto", "protected": { "desc": "Todas las pipelines se tienen que estar aprobados antes de que se ejecuten.", "protected": "Protegido" }, "save": "Guardar configuración", "success": "Configuraciónes del proyecto actualizadas", "timeout": { "minutes": "minutos", "timeout": "Tiempo de espera" }, "trusted": { "desc": "Los contenedores de pipeline subyacentes obtienen acceso a capacidades escaladas como el montaje de volúmenes.", "trusted": "Confiado", "volumes": { "volumes": "Volúmenes" }, "security": { "security": "Seguridad" } }, "visibility": { "internal": { "desc": "Sólo los usuarios autentificados de la instancia Woodpecker pueden ver este proyecto.", "internal": "Interno" }, "private": { "desc": "Sólo usted y otros propietarios del repositorio pueden ver este proyecto.", "private": "Privado" }, "public": { "desc": "Todos los usuarios pueden ver tu proyecto sin necesidad de iniciar sesión.", "public": "Público" }, "visibility": "Visibilidad del proyecto" }, "allow_deploy": { "allow": "Permitir despliegues" } }, "not_allowed": "No tienes acceso a las configuraciónes del repositorio", "registries": { "add": "Añadir registry", "address": { "address": "Dirección", "placeholder": "Dirección del registry (por ejemplo, docker.io)" }, "created": "Credenciales del registry creadas", "credentials": "Credenciales del registry", "delete": "Eliminar registry", "deleted": "Credenciales del registry eliminadas", "desc": "Se pueden añadir credenciales de registries para utilizar imágenes privadas para su pipeline.", "edit": "Editar registry", "none": "Aún no hay credenciales de registry.", "registries": "Registries", "save": "Guardar registry", "saved": "Credenciales de registry guardadas", "show": "Mostrar registries" }, "secrets": { "add": "Añadir secreto", "created": "Secreto creado", "delete": "Eliminar secreto", "delete_confirm": "¿Realmente quieres eliminar este secreto?", "deleted": "Secreto eliminado", "desc": "Los secretos pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales del pipeline.", "edit": "Editar secreto", "events": { "events": "Disponible en los siguientes eventos", "pr_warning": "Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos." }, "images": { "desc": "Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes", "images": "Disponible para las siguientes imágenes" }, "name": "Nombre", "none": "No hay secretos aún.", "plugins_only": "Sólo disponible para plugins", "save": "Guardar secreto", "saved": "Secreto guardado", "secrets": "Secretos", "show": "Mostrar secretos", "value": "Valor" }, "settings": "Configuración" }, "user_none": "Esta organización / usuario aún no tiene proyectos.", "visibility": { "visibility": "Visibilidad del proyecto", "public": { "public": "Publico", "desc": "Cualquier persona puede ver su proyecto sin iniciar sesión." }, "private": { "private": "Privado", "desc": "Solo usted y los otros propietarios de el repositorio pueden ver este proyecto." }, "internal": { "internal": "Interno", "desc": "Solo los usuarios autenticados de la instancia de Woodpecker pueden ver el proyecto." } } }, "repos": "Repositorios", "repositories": { "title": "Repositorios", "all": { "title": "Todos los repositorios", "desc": "Repositorios ordenados por última creación de flujo" }, "last": { "title": "Última visita", "desc": "Repositorios visitados recientemente ordenados por tiempo de acceso" } }, "running_version": "Está ejecutando Woodpecker {0}", "search": "Buscar…", "time": { "days_short": "d", "hours_short": "h", "min_short": "min", "not_started": "no iniciado aún", "sec_short": "s", "template": "MMM D, YYYY, HH:mm z", "weeks_short": "w", "just_now": "Justo ahora" }, "unknown_error": "Se ha producido un error desconocido", "update_woodpecker": "Por favor, actualice su instancia de Woodpecker a {0}", "url": "URL", "user": { "access_denied": "No puede iniciar sesión", "internal_error": "Se ha producido algún error interno", "oauth_error": "Error al autenticarse con el proveedor OAuth", "settings": { "api": { "api": "API", "api_usage": "Ejemplo de uso de la API", "cli_usage": "Ejemplo de uso de la CLI", "desc": "Token de acceso personal y uso de la API", "dl_cli": "Descargar CLI", "reset_token": "Restablecer token", "shell_setup": "Configuración de Shell", "shell_setup_before": "realice los pasos de configuración del shell antes", "swagger_ui": "Swagger UI", "token": "Token de acceso personal" }, "general": { "general": "General", "language": "Idioma", "theme": { "auto": "Auto", "dark": "Oscuro", "light": "Claro", "theme": "Tema" } }, "secrets": { "add": "Añadir secreto", "created": "Secreto de usuario creado", "deleted": "Secreto de usuario eliminado", "desc": "Los secretos de usuario pueden pasarse en tiempo de ejecución como variables de entorno a pasos individuales de cualquier pipeline del usuario.", "events": { "events": "Disponible en los siguientes eventos", "pr_warning": "Tenga cuidado con esta opción, ya que un individuo malintencionado puede enviar un pull request malicioso que exponga sus secretos." }, "images": { "desc": "Lista separada por comas de las imágenes en las que este secreto está disponible, deje vacío para permitir todas las imágenes", "images": "Disponible para las siguientes imágenes" }, "name": "Nombre", "none": "Aún no hay secretos de usuario.", "plugins_only": "Sólo disponible para plugins", "save": "Guardar secreto", "saved": "Secreto de usuario guardado", "secrets": "Secretos", "show": "Mostrar secretos", "value": "Valor" }, "settings": "Configuración de usuario" } }, "username": "Nombre de usuario", "welcome": "Bienvenido a Woodpecker", "empty_list": "¡No se ha encontrado {entidad}!", "org_level_secret": "secreto de organización", "global_level_secret": "secreto global", "login_with": "Iniciar sesión con {forge}", "extensions": "Extensiones", "extensions_configuration_saved": "Configuración de extensiones guardada", "require_approval": { "require_approval_for": "Requisitos aprobados", "allowed_users": { "allowed_users": "Usuarios permitidos" } }, "no_search_results": "No se han encontrado resultados", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "public_only": "Solo público", "extensions_signatures_public_key": "Clave pública para la firma", "login_to_woodpecker_with": "Iniciar sesión en Woodpecker con" } ================================================ FILE: web/src/assets/locales/fi.json ================================================ { "back": "Takaisin", "cancel": "Peru", "docs": "Dokumentaatio", "login": "Kirjaudu", "logout": "Kirjaudu ulos", "password": "Salasana", "search": "Etsi…", "url": "URL", "username": "Käyttäjätunnus", "welcome": "Tervetuloa Woodpeckeriin" } ================================================ FILE: web/src/assets/locales/fr.json ================================================ { "admin": { "settings": { "agents": { "add": "Ajouter un agent", "agents": "Agents", "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "badge": "capacité", "capacity": "Capacité", "desc": "Le nombre maximum de pipelines exécutées en parallèle par cet agent." }, "created": "Agent crée", "delete_agent": "Supprimer l'agent", "delete_confirm": "Voulez vous vraiment supprimer cet agent ? Il ne pourra plus se connecter sur le serveur.", "deleted": "Agent supprimé", "desc": "Agents enregistrés sur ce serveur.", "edit_agent": "Éditer l'agent", "id": "ID", "last_contact": "Dernier contact", "name": { "name": "Nom", "placeholder": "Nom de l'agent" }, "never": "Jamais", "no_schedule": { "name": "Désactiver l'agent", "placeholder": "Bloquer l'assignation de nouvelles tâches sur l'agent" }, "none": "Il n'y a pas d'agent pour le moment.", "platform": { "badge": "platforme", "platform": "Platforme" }, "save": "Enregistrer l'agent", "saved": "Agent enregistré", "show": "Afficher les agents", "token": "Jeton", "version": "Version" }, "not_allowed": "Vous n'êtes pas autorisé à accéder aux réglages du serveur.", "orgs": { "delete_confirm": "Voulez-vous vraiment supprimer cette organisation ? Cela supprimera tous les dépôts que possède cette organisation.", "delete_org": "Supprimer l'organisation", "deleted": "Organisation supprimée", "desc": "Organisations possédant des dépôts sur ce serveur.", "none": "Il n'y a pas encore d'organisation.", "org_settings": "Réglages de l'organisation", "orgs": "Organisations", "view": "Voir l'organisation" }, "queue": { "agent": "agent", "desc": "Tâches en attente d’exécution par des agents", "pause": "Mettre en pause", "paused": "La queue est en pause", "queue": "Queue", "resume": "Relancer", "resumed": "La queue est repartie", "stats": { "completed_count": "Tâches complétées", "pending_count": "En attente", "running_count": "En cours d’exécution", "waiting_on_deps_count": "En attente de dépendances", "worker_count": "Libre" }, "task_pending": "La tâche est en attente", "task_running": "La tâche est en cours d’exécution", "task_waiting_on_deps": "La tâche est en attente de ses dépendances", "tasks": "Tâches", "waiting_for": "en attente de" }, "repos": { "desc": "Dépôts actifs ou anciennement actifs sur ce serveur.", "disabled": "Désactivé", "none": "Il n'y a pas encore de dépôts.", "repair": { "repair": "Tout réparer", "success": "Dépôts réparés" }, "repos": "Dépôts", "settings": "Réglages du dépôt", "view": "Voir le dépôt" }, "secrets": { "add": "Ajouter un secret", "created": "Secret global crée", "deleted": "Secret global supprimé", "desc": "Les secrets globaux sont transmis sous forme de variable d’environnement lors de l’exécution de toutes les étapes d'un pipeline.", "events": { "events": "Disponible pour les événements suivants", "pr_warning": "Faites attention avec cette option car un acteur malicieux peut soumettre une pull request qui révélerait vos secrets." }, "images": { "desc": "Liste des images pour lesquelles ce secret est accessible, laisser vide pour donner l’accès à toutes les images", "images": "Disponible pour les images suivantes" }, "name": "Nom", "none": "Il n'y a pas de secrets globaux.", "plugins_only": "Disponible uniquement pour les plugins", "save": "Enregistrer un secret", "saved": "Secret global enregistré", "secrets": "Secrets", "show": "Afficher les secrets", "value": "Valeur", "warning": "Ces secrets seront disponibles pour tout les comptes." }, "settings": "Réglages", "users": { "add": "Ajouter un compte utilisateur", "admin": { "admin": "Administrateur", "placeholder": "Le compte utilisateur est un administrateur" }, "avatar_url": "URL de l'avatar", "cancel": "Annuler", "created": "Compte utilisateur créé", "delete_confirm": "Voulez vous vraiment supprimer ce compte utilisateur ? Cela supprimera tout les dépôts que possède ce compte utilisateur.", "delete_user": "Supprimer le compte utilisateur", "deleted": "Compte utilisateur supprimé", "desc": "Utilisateurs enregistrés sur le serveur", "edit_user": "Éditer le compte utilisateur", "email": "Courriel", "login": "Login", "none": "Il n'y a pas de compte utilisateur pour le moment.", "save": "Enregistrer le compte utilisateur", "saved": "Compte utilisateur enregistré", "show": "Afficher les comptes utilisateurs", "users": "Utilisateurs" }, "registries": { "warning": "Ces codes d’accès au registre seront disponibles pour tout les utilisateurs.", "desc": "Les codes d'accès aux registres globaux peuvent être ajouter pour utiliser les images privées sur tout les pipelines." } } }, "api": "API", "back": "Revenir en arrière", "cancel": "Annuler", "default": "défaut", "docs": "Docs", "documentation_for": "Documentation sur \"{topic}\"", "errors": { "not_found": "Le serveur n'a pas pu trouver l'objet demandé" }, "global_level_secret": "Secret global", "info": "Information", "login": "Connexion", "logout": "Déconnexion", "not_found": { "back_home": "Retour à l'accueil", "not_found": "Whoa 404, soit nous avons cassé quelque chose, soit vous avez fait une faute de frappe :-/" }, "org": { "settings": { "not_allowed": "Vous n’êtes pas autorisé à accéder aux réglages de cette organisation", "secrets": { "add": "Ajouter un secret", "created": "Secret d'organisation crée", "deleted": "Secret d'organisation supprimé", "desc": "Les secrets d'organisation sont transmis sous forme de variable d’environnement à chaque étape d'un pipeline de tout les dépôts de l'organisation.", "events": { "events": "Disponible pour les événements suivants", "pr_warning": "Faites attention avec cette option car un acteur malicieux pourrait soumettre une pull request qui va afficher vos secrets." }, "images": { "desc": "Liste des images pour lesquelles ce secret est accessible, laisser vide pour donner l’accès à toutes les images", "images": "Disponible pour les images suivantes" }, "name": "Nom", "none": "Il n'y a pas de secrets d'organisation.", "plugins_only": "Disponible uniquement pour les plugins", "save": "Enregistrer un secret", "saved": "Secret d'organisation enregistré", "secrets": "Secrets", "show": "Afficher les secrets", "value": "Valeur" }, "settings": "Réglages", "registries": { "desc": "Les codes d'accès aux registres de l'organisation peuvent être ajouté pour utiliser les images privées sur tout les pipelines de l'organisation." } } }, "org_level_secret": "Secret d'organisation", "password": "Mot de passe", "pipeline_feed": "Fil du pipeline", "repo": { "activity": "Activité", "add": "Ajouter un dépôt", "branches": "Branches", "deploy_pipeline": { "enter_target": "Environnement de 'déploiement' ciblé", "title": "Déclenchement d'un événement de 'déploiement' pour le pipeline courant #{pipelineId}", "trigger": "Déployer", "variables": { "add": "Ajouter une variable", "desc": "Spécifiez les variables additionnelles que votre pipeline va utiliser. Les variables portant le même nom seront écrasées.", "name": "Nom de la variable", "title": "Variables additionnelles du pipeline", "value": "Valeur de la variable", "delete": "Supprimer la variable" }, "enter_task": "Tâche de déploiement" }, "enable": { "disabled": "Désactivé", "enable": "Activer", "enabled": "Déjà activé", "list_reloaded": "Liste des dépôts actualisée", "reload": "Actualiser les dépôts", "success": "Dépôt activé" }, "manual_pipeline": { "select_branch": "Sélectionner une branche", "title": "Déclencher manuellement une exécution de pipeline", "trigger": "Exécuter un pipeline", "variables": { "add": "Ajouter une variable", "desc": "Ajoutez des variables pour le lancement du pipeline. Les variables existantes seront écrasés.", "name": "Nom de la variable", "title": "Variables de pipeline supplémentaire", "value": "Valeur de la variable", "delete": "Supprimer la variable" }, "show_pipelines": "Afficher les pipelines" }, "not_allowed": "Vous n'êtes pas autorisé à accéder à ce dépôt", "open_in_forge": "Ouvrir le dépôt dans la forge", "pipeline": { "actions": { "cancel": "Annuler", "cancel_success": "Pipeline annulé", "canceled": "Cette étape a été annulée.", "deploy": "Déployer", "log_auto_scroll": "Automatiquement défiler vers le bas", "log_auto_scroll_off": "Désactiver le défilement automatique", "log_download": "Télécharger", "restart": "Redémarrer", "restart_success": "Pipeline redémarré", "log_delete": "Supprimer" }, "config": "Configuration", "errors": "Erreurs ({count})", "event": { "cron": "Tâche périodique", "deploy": "Déploiement", "manual": "Manuel", "pr": "Pull Request", "push": "Push", "tag": "Tag", "release": "Release", "pr_closed": "Pull Request fusionnée / fermée" }, "exit_code": "Code de retour {exitCode}", "files": "Fichiers changés ({files})", "loading": "Chargement…", "log_download_error": "Il y a eu une erreur lors du téléchargement du fichier de journal", "log_title": "Journal des étapes", "no_files": "Aucun fichier n'a été modifié.", "no_pipeline_steps": "Aucune étape disponible !", "no_pipelines": "Aucun pipeline n'a démarré pour le moment.", "pipeline": "Pipeline #{pipelineId}", "pipelines_for": "Pipelines pour la branche \"{branch}\"", "pipelines_for_pr": "Pipeline pour la pull request #{index}", "protected": { "approve": "Approuver", "approve_success": "Pipeline approuvé", "awaits": "Ce pipeline attend d'être approuvé par un mainteneur !", "decline": "Refuser", "decline_success": "Pipeline refusé", "declined": "Le pipeline a été refusé !", "review": "Vérifier les changements" }, "show_errors": "Afficher les erreurs", "status": { "blocked": "bloqué", "declined": "refusé", "error": "en erreur", "failure": "échoué", "killed": "tué", "pending": "en attente", "running": "en cours", "skipped": "sauté", "started": "démarré", "status": "État : {status}", "success": "réussi" }, "step_not_started": "L'étape n'a pas démarré encore.", "tasks": "Tâches", "warnings": "Avertissements ({count})", "we_got_some_errors": "Oh non, il y a des erreurs !", "log_delete_error": "Il y a eu une erreur lors de la suppression des logs", "log_delete_confirm": "Voulez vous vraiment supprimer les logs de cette étape ?", "no_logs": "Aucun logs", "duration": "Durée du pipeline", "created": "Crée : {created}", "debug": { "title": "Débogage", "download_metadata": "Télécharger les métadonnées", "metadata_download_error": "Erreur lors du téléchargement des métadonnées" } }, "pull_requests": "Pull requests", "settings": { "actions": { "actions": "Actions", "delete": { "confirm": "Toutes les données vont être perdues aprés cette action ! ! !\n\nVoulez vous vraiment continuer ?", "delete": "Supprimer le dépôt", "success": "Dépôt supprimé" }, "disable": { "disable": "Désactiver le dépôt", "success": "Dépôt désactivé" }, "enable": { "enable": "Activer le dépôt", "success": "Dépôt activé" }, "repair": { "repair": "Réparer un dépôt", "success": "Dépôt réparé" } }, "badge": { "badge": "Badge", "branch": "Branche", "type": "Syntaxe", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL" }, "crons": { "add": "Ajouter une tâche planifiée", "branch": { "placeholder": "Branche (utilise la branche par défaut si non renseigné)", "title": "Branche" }, "created": "Tâche planifiée crée", "crons": "Tâches planifiées", "delete": "Supprimer la tâche planifiée", "deleted": "Tâche planifiée supprimée", "desc": "Les tâches planifiées peuvent déclencher des pipelines à intervalles réguliers.", "edit": "Modifier la tâche planifiée", "name": { "name": "Nom", "placeholder": "Nom de la tâche périodique" }, "next_exec": "Prochaine exécution", "none": "Il n'y a pas de tâche planifié pour le moment.", "not_executed_yet": "Non exécuté pour le moment", "run": "Lancer immédiatement", "save": "Enregistrer la tâche planifiée", "saved": "Tâche planifiée enregistrée", "schedule": { "placeholder": "Horaire", "title": "Horaire (basé sur UTC)" }, "show": "Afficher les tâches planifiées" }, "general": { "allow_pr": { "allow": "Autoriser les demandes de fusions", "desc": "Permettre aux pipelines de se déclencher sur les pull requests." }, "cancel_prev": { "cancel": "Annuler les pipelines précédents", "desc": "Activer pour forcer l'annulation des pipelines en cours et en attente pour le même contexte et le même événement avant de démarrer un nouveau pipeline déclenché par un événement." }, "general": "Général", "netrc_only_trusted": { "desc": "Les plugins listés ici auront accès aux identifiants netrc qui peuvent être utiliser pour cloner des dépôts depuis la forge, ou pour pousser vers celle-ci.", "netrc_only_trusted": "Plugins de clonage personnalisés de confiance" }, "pipeline_path": { "default": "Par défaut : .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Le chemin vers votre configuration de pipeline (par example {0}). Les dossiers doivent finir par {1}.", "desc_path_example": "mon/chemin/", "path": "Chemin vers le pipeline" }, "project": "Paramètres du projet", "protected": { "desc": "Chaque pipeline doit être approuvé avant d'être exécuté.", "protected": "Protégé" }, "save": "Enregistrer les paramètres", "success": "Paramètres du projet mis à jour", "timeout": { "minutes": "minutes", "timeout": "Délai d’inactivité" }, "trusted": { "desc": "Les conteneurs du pipeline ont accès à des capacités privilégiées (comme le montage de volumes).", "trusted": "Vérifié", "network": { "network": "Réseau" }, "volumes": { "volumes": "Volumes" }, "security": { "security": "Sécurité" } }, "visibility": { "internal": { "desc": "Seuls les utilisateurs authentifiés de l'instance peuvent voir ce projet.", "internal": "Interne" }, "private": { "desc": "Seulement vous et les autres propriétaires de ce dépôt peuvent voir ce projet.", "private": "Privée" }, "public": { "desc": "Tout les utilisateurs peuvent voir votre projet sans être connecté.", "public": "Publique" }, "visibility": "Visibilité du projet" }, "allow_deploy": { "allow": "Autoriser les événements de 'déploiement'.", "desc": "Permettre les déploiements depuis les pipelines ayant réussis. À utiliser que si vous avez confiance dans les utilisateurs ayant un accès en écriture." } }, "not_allowed": "Vous n'êtes pas autorisé à accéder aux paramètres de ce dépôt", "registries": { "add": "Ajouter un registre", "address": { "address": "Adresse", "placeholder": "Adresse du registre (e.g. docker.io)" }, "created": "Authentifiant de connexion à un registre crée", "credentials": "Authentifiants de connexion à un registre", "delete": "Supprimer le registre", "deleted": "Authentifiant de connexion à un registre supprimé", "desc": "Des authentifiants de connexion pour les registres peuvent être ajouté pour permettre d'utiliser des images privées pour vos pipelines.", "edit": "Modifier le registre", "none": "Il n'y a pas d’authentifiant de connexion à un registre pour le moment.", "registries": "Registres", "save": "Enregistrer le registre", "saved": "Authentifiant de connexion à un registre enregistré", "show": "Afficher les registres" }, "secrets": { "add": "Ajouter un secret", "created": "Secret crée", "delete": "Supprimer le secret", "delete_confirm": "Voulez vous vraiment supprimer ce secret ?", "deleted": "Secret supprimé", "desc": "Les secrets sont transmis sous forme de variable d’environnement lors de l’exécution d'une étape d'un pipeline.", "edit": "Modifier le secret", "events": { "events": "Disponible pour les événements suivants", "pr_warning": "Faites attention avec cette option car un acteur malicieux pourrait soumettre une pull request qui va afficher vos secrets." }, "images": { "desc": "Liste des images pour lesquelles ce secret est accessible, laisser vide pour donner l’accès à toutes les images", "images": "Disponible pour les images suivantes" }, "name": "Nom", "none": "Il n'y a pas de secrets pour le moment.", "plugins_only": "Disponible uniquement pour les plugins", "save": "Enregistrer un secret", "saved": "Secret enregistré", "secrets": "Secrets", "show": "Afficher les secrets", "value": "Valeur" }, "settings": "Paramètres" }, "user_none": "Cet(te) organisation / utilisateur n'a pas encore de projets.", "visibility": { "visibility": "Visibilité du projet", "public": { "public": "Publique", "desc": "Tout le monde peut voir le projet sans être connecté." }, "private": { "private": "Privé", "desc": "Seul vous et les propriétaires de ce dépôt peuvent le voir." }, "internal": { "internal": "Interne", "desc": "Seuls les comptes identifiés sur cet instance de Woodpecker peuvent voir ce projet." } } }, "repos": "Dépôt", "repositories": { "title": "Dépôts", "all": { "title": "Tout les dépôts", "desc": "Dépôts classés par date de création du dernier pipeline" }, "last": { "title": "Dernière visite" } }, "running_version": "Vous utilisez Woodpecker {0}", "search": "Rechercher…", "time": { "days_short": "j", "hours_short": "h", "min_short": "min", "not_started": "pas encore démarré", "sec_short": "sec", "template": "D MMM, YYYY, HH:mm z", "weeks_short": "s", "just_now": "il y a peu de temps" }, "unknown_error": "Une erreur inconnue est survenue", "update_woodpecker": "Merci de mettre à jour votre instance Woodpecker vers la version {0}", "url": "URL", "user": { "access_denied": "Vous n'êtes pas autorisé à vous connecter", "internal_error": "Une erreur interne est arrivé", "oauth_error": "Erreur lors de l’authentification auprès du fournisseur OAuth", "settings": { "api": { "api": "API", "api_usage": "Exemple d'utilisation de l'API", "cli_usage": "Exemple d'utilisation de l'interface en ligne de commande", "desc": "Jeton d'Accès Personnel et usage de l'API", "dl_cli": "Télécharger l'Interface en ligne de commande", "reset_token": "Réinitialiser le jeton", "shell_setup": "Configuration de l'interpréteur de commande", "shell_setup_before": "Faites les étapes de configuration de l'interpréteur de commande avant", "swagger_ui": "Interface Swagger", "token": "Jeton d'Accès Personnel" }, "general": { "general": "Général", "language": "Langue", "theme": { "auto": "Automatique", "dark": "Sombre", "light": "Clair", "theme": "Thème" } }, "secrets": { "add": "Ajouter un secret", "created": "Secret d'utilisateur crée", "deleted": "Secret d'utilisateur supprimé", "desc": "Les secrets d'utilisateur peuvent être passés à toutes les étapes d'un pipeline personnel sous forme de variables d'environnement.", "events": { "events": "Disponible pour les événements suivants", "pr_warning": "Attention, si cette option est activée, un acteur malveillant peut proposer une pull request qui affiche vos secrets." }, "images": { "desc": "Liste des où ce secret sera utilisable, laisser vide pour autoriser toutes les images", "images": "Disponible pour les images suivantes" }, "name": "Nom", "none": "Il n'y a pas encore de secrets d'utilisateur.", "plugins_only": "Disponible uniquement pour les plugins", "save": "Enregistrer le secret", "saved": "Secret d'utilisateur enregistré", "secrets": "Secrets", "show": "Afficher les secrets", "value": "Valeur" }, "settings": "Paramètres du compte utilisateur", "cli_and_api": { "desc": "Jeton d'Accès Personnel, ligne de command et usage de l'API", "token": "Jeton d'Accès Personnel", "api_usage": "Exemple d'utilisation de l'API", "download_cli": "Télécharger l'interface en ligne de commande", "reset_token": "Réinitialiser le jeton\"", "cli_and_api": "Ligne de commande et API", "cli_usage": "Exemple d'utilisation de la ligne de commande", "swagger_ui": "Interface Swagger" } } }, "username": "Nom d'utilisateur", "welcome": "Bienvenue sur Woodpecker", "empty_list": "Aucune {entity} trouvée !", "login_to_cli": "Login en ligne de commande", "abort": "Abandonner", "cli_login_success": "Connexion en ligne de commande réussie", "cli_login_failed": "Connexion en ligne de commande échouée", "cli_login_denied": "Connexion à la ligne de commande interdite", "return_to_cli": "Vous pouvez fermer cet onglet et revenir à la ligne de commande.", "login_to_cli_description": "En continuant, vous serez connecté via la ligne de commande.", "secrets": { "secrets": "Secrets", "desc": "Les secrets peuvent être transmis aux différentes étapes de la pipeline en tant que variables d'environnement.", "images": { "desc": "Liste des images pour lesquelles ce secret est disponible, laisser vide pour autoriser toutes les images", "images": "Disponible pour les images suivantes" }, "none": "Il n'y a pas encore de secrets.", "add": "Ajouter un secret", "save": "Sauvegarder le secret", "show": "Afficher les secrets", "name": "Nom", "value": "Valeur", "deleted": "Secret supprimé", "created": "Secret créé", "saved": "Secret sauvegardé", "events": { "events": "Disponible lors des événements suivants", "pr_warning": "Soyez prudent avec cette option : un acteur malveillant peut soumettre une pull request qui expose vos secrets." }, "plugins_only": "Uniquement disponible pour les plugins", "edit": "Modifier le secret", "delete": "Supprimer le secret", "delete_confirm": "Voulez vous vraiment supprimer ce secret ?", "plugins": { "images": "Disponible pour les plugins suivants" } }, "internal_error": "Une erreur interne est survenue", "access_denied": "Vous n'êtes pas autorisé à accéder à cette instance", "oauth_error": "Erreur lors de l'authentification auprès du fournisseur OAuth", "registration_closed": "Les inscriptions sont fermés", "settings": "Réglages", "registries": { "registries": "Registres", "delete_confirm": "Voulez vous vraiment supprimer ce registre ?", "deleted": "Codes d'accès aux registres supprimés", "desc": "Les codes d'accès aux registres peuvent être ajouté pour utiliser des images privées sur les pipelines.", "credentials": "Codes d’accès aux registres", "edit": "Éditer le registre", "delete": "Supprimer le registre", "add": "Ajouter un registre", "view": "Voir le registre", "save": "Enregistrer le registre", "show": "Afficher les registres", "address": { "address": "Adresse", "desc": "Adresse du registre (e.g. docker.io)" }, "saved": "Codes d'accès aux registres enregistrés", "created": "Codes d’accès aux registres crées", "none": "Il n'y a pas de code d’accès aux registres." }, "invalid_state": "L'état OAuth est invalide", "by_user": "par {user}", "pushed_to": "poussé vers", "closed": "fermé", "deployed_to": "déployé vers", "created": "crée", "triggered": "déclenché", "pipeline_duration": "Durée du pipeline", "pipeline_since": "Pipeline crée {created} minutes ago", "pipeline_has_warnings": "Le pipeline a des alertes", "pipeline_has_errors": "Le pipeline a des erreurs", "login_with": "Se connecter avec {forge}" } ================================================ FILE: web/src/assets/locales/hu.json ================================================ { "back": "Vissza", "logout": "Kijelentkezés", "search": "Keresés…", "username": "Felhasználónév", "unknown_error": "Ismeretlen hiba történt", "password": "Jelszó", "repo": { "visibility": { "public": { "public": "Nyilvános", "desc": "Bárki láthatja a projekted, belépés nélkül." }, "private": { "private": "Privát", "desc": "Csak te és a többi tulajdonos láthatjátok ezt a projektet." }, "internal": { "internal": "Belső", "desc": "Csak authentikált felhasználók láthatják a Woodpecker oldalát ennek a projektnek." }, "visibility": "Projekt láthatósága" }, "settings": { "general": { "timeout": { "minutes": "percek", "timeout": "Időtúllépés" }, "save": "Beállítások mentése", "project": "Projekt beállítások", "success": "Projekt beállítások frissítve", "cancel_prev": { "cancel": "Előző pipelineok leállítása", "desc": "A kiválasztott esemény elődizések leállítják a függőben lévő és futó pipelineokat ugyanazon eseménynél, mielőtt elindítaná a következőt." }, "pipeline_path": { "default": "Alapértelmezetten: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "path": "Pipeline elérési útja", "desc_path_example": "saját/elérési_utam/", "desc": "Az élérési út a pipeline konfigurációdhoz (például {0}). A Mappáknak {1}-ra kéne végződnie." }, "general": "Általános", "allow_pr": { "allow": "Pull Requestek bekapcsolása", "desc": "A pipelineok lefuttatásának engedélyezése a pull requesteken." }, "trusted": { "trusted": "Megbízott", "network": { "desc": "Pipeline konténerek hálózati hozzáférést kapnak mint például a DNS megváltoztatásához.", "network": "Hálózat" }, "security": { "security": "Biztonság", "desc": "A pipeline adattárolók hozzáférést kapnak a biztonsági jogosultságokhoz." }, "volumes": { "volumes": "Adattárolók", "desc": "A pipeline konténerek számára engedélyezett az adattárolók csatolása." } }, "allow_deploy": { "allow": "Üzembe helyezések bekapcsolása", "desc": "Engedélyezi az üzembe helyezését a sikeres pipelineoknak. Minden felhasználó push hozzáféréssel tud ilyet előidézni, szóval csak óvatosan." }, "netrc_only_trusted": { "netrc_only_trusted": "Egyedi megbízott klón pluginok", "desc": "A pluginok amelyek hozzáférést kapnak a netrc hozzáférési adatokhoz tudnak repositorykat klónozni az adott kódplatformról vagy pusholni az adott kódplatformra." } }, "actions": { "disable": { "disable": "Tároló inaktiválása", "success": "Repository kikapcsolva" }, "delete": { "delete": "Repository törlése", "success": "Repository törölve", "confirm": "Minden adat el fog veszni ez az akció után!\n\nBiztos, hogy folytatni akarod?" }, "actions": "Actions", "repair": { "repair": "Repository javítása", "success": "Repository javítva" }, "enable": { "enable": "Repository bekapcsolása", "success": "Repository bekapcsolva" } }, "crons": { "none": "Nincsenek még cronok.", "save": "Cron mentése", "created": "Cron létrehozva", "saved": "Cron elmentve", "desc": "A cron feladatok tudnak pipelineokat indítani időközönként.", "next_exec": "Következő végrehajtás", "run": "Futtatás most", "deleted": "Cron törölve", "not_executed_yet": "Még nincs végrehajtva", "branch": { "title": "Branch", "placeholder": "Branch (az alap branchet használja ha üres)" }, "name": { "name": "Név", "placeholder": "A cron feladat neve" }, "schedule": { "title": "Menetrend (UTC időzónán alapozva)", "placeholder": "Menetrend" }, "crons": "Cronok", "edit": "Cron szerkesztése", "delete": "Cron törlése", "show": "Cronok mutatása", "add": "Cron hozzáadása" }, "not_allowed": "Nincs hozzáférésed ennek a repositorynak a beállításaihoz", "badge": { "branch": "Branch", "type_html": "HTML", "type_url": "URL", "type_markdown": "Markdown", "badge": "Jelvény", "type": "Szintaxis" } }, "pipeline": { "loading": "Betöltés…", "actions": { "canceled": "Ez a lépés le lett állítva.", "log_download": "Letöltés", "log_delete": "Törlés", "restart_success": "Pipeline újraindítva", "log_auto_scroll": "Automata görgetés bekapcsolása", "cancel_success": "Pipeline leállítva", "deploy": "Üzembe helyezés", "log_auto_scroll_off": "Automata görgetés kikapcsolása", "cancel": "Mégse", "restart": "Újraindítás" }, "event": { "cron": "Időzítő", "pr_closed": "Pull Request egyesítve/lezárva", "release": "Kiadás", "manual": "Manuális", "tag": "Bélyeg", "pr": "Pull request", "deploy": "Üzembe helyezés", "push": "Push" }, "debug": { "title": "Debuggolás", "download_metadata": "Metaadat letöltése", "metadata_download_error": "Hiba metaadat letöltése közben", "metadata_download_successful": "Metadat sikeresen letöltve", "no_permission": "Nincs jogosultságod a debug információkhoz", "metadata_exec_title": "Pipeline újrafuttatása lokálisan", "metadata_exec_desc": "A pipeline metaadatjának letöltése, hogy lokálisan lehessen futtatni. Így kitudsz javítani hibákat benne és változtatásokat tesztelni mielőtt véglegesítenéd őket. A `woodpecker-cli` lokális verziójának és a Woodpecker Szerver verziójának egyeznie kell." }, "no_logs": "Nincsen napló", "status": { "pending": "folyamatban", "started": "elindítva", "skipped": "átlépett", "running": "fut", "status": "Státusz: {status}", "blocked": "blokkolva", "success": "sikeres", "declined": "elutasítva", "error": "hiba", "failure": "sikertelen", "killed": "terminálva" }, "log_delete_confirm": "Biztos, hogy akarod ehhez a lépéshez tartozó naplót törölni?", "protected": { "awaits": "Ez a pipeline a fenntartó elfogadására vár!", "declined": "Ezt a pipeline-t elutasították!", "approve_success": "Pipeline elfogadva", "approve": "Elfogadás", "decline": "Elutasítás", "decline_success": "Pipeline elutasítva" }, "created": "Készítve: {created}", "duration": "Pipeline lefutási ideje", "config": "Konfiguráció", "files": "Megváltoztatott fájlok", "errors": "Hibák", "warnings": "Figyelmeztetések", "show_errors": "Hibák mutatása", "we_got_some_errors": "Jaj ne, hiba történt!", "log_delete_error": "Hiba lépett fel a lépés naplójának törlése közben", "exit_code": "Kilépési kód {exitCode}", "log_download_error": "Hiba lépett fel a napló letöltése közben", "no_pipeline_steps": "Nincsenek elérhető pipeline lépések!", "pipelines_for": "\"{branch}\" branchhez tartozó pipelineok", "pipelines_for_pr": "#{index} pull requesthez tartozó pipelineok", "tasks": "Feladatok", "no_pipelines": "Egy pipeline sem indult még el.", "step_not_started": "Ez a lépés még nem indult el.", "pipeline": "#{pipelineId} Pipeline", "log_title": "Lépés napló" }, "manual_pipeline": { "variables": { "delete": "Változó törlése", "title": "Egyéb pipeline változók", "desc": "Adj meg egyéb változókat, hogy használhasd a pipelineodban. A megegyező nevű változók felülírják egymást.", "name": "Változó neve", "value": "Változó értéke" }, "select_branch": "Branch választás", "title": "Manuális pipeline futtatás előidézése", "trigger": "Pipeline futtatása", "show_pipelines": "Pipelineok mutatása" }, "deploy_pipeline": { "variables": { "delete": "Változó törlése", "value": "Változó értéke", "title": "Egyéb pipeline változók", "desc": "Adj meg egyéb változókat, hogy használhasd a pipelineodban. A megegyező nevű változók felülírják egymást.", "name": "Változó neve" }, "enter_target": "Az üzembe helyezés célkörnyezete", "title": "Idézz elő egy üzembe helyezést a jelenlegi #{pipelineId} pipeline-hoz", "trigger": "Üzembe helyezés", "enter_task": "Üzembe helyezési feladat" }, "activity": "Aktivitás", "branches": "Branchek", "pull_requests": "Pull requestek", "add": "Repository hozzáadása", "user_none": "Ez az organizáció/felhasználó nem rendelkezik még semmilyen projektekkel", "not_allowed": "Nincs hozzáférésed ehhez a repositoryhoz", "enable": { "disabled": "Kikapcsolva", "success": "Repository bekapcsolva", "enabled": "Már bekapcsolva", "enable": "Bekapcsolás" }, "open_in_forge": "Repository megnyitása a kódplatformban" }, "admin": { "settings": { "users": { "show": "Mutasd a felhasználokat", "cancel": "Mégse", "save": "Felhasználó mentése", "add": "Felhasználó hozzáadása", "deleted": "Felhasználó törölve", "created": "Felhasználó létrehozva", "saved": "Felhasználó mentve", "delete_user": "Felhasználó törlése", "edit_user": "Felhasználó szerkesztése", "desc": "Regisztrált felhasználók a szerveren.", "avatar_url": "Avatár URL", "users": "Felhasználók", "email": "Email", "login": "Bejelentkezés", "none": "Még nincenek felhasználók.", "delete_confirm": "Biztos, hogy akarod ezt a felhasználót törölni? Minden repository ami a felhasználóhoz tartozik is törlődik.", "admin": { "admin": "Adminisztrátor", "placeholder": "A felhasználó egy adminisztrátor" } }, "orgs": { "orgs": "Szervezetek", "delete_org": "Szervezet törlése", "delete_confirm": "Biztos, hogy akarod ezt az organizációt törölni? Minden repository ami az organizációhoz tartozik is törlődik.", "deleted": "Organizáció törölve", "view": "Organizáció megnézése", "desc": "Organizációk amelyek repositorykat tart ezen a szerveren.", "none": "Még nincsenek organizációk.", "org_settings": "Organizáció beállítások" }, "queue": { "resumed": "Feldolgozási sor folytatása", "tasks": "Feladatok", "task_running": "Feladat folyamatban", "pause": "Szüneteltetés", "paused": "Feldolgozási sor szünteltetve", "task_pending": "Feladat futtatása függőben", "queue": "Feldolgozási sor", "desc": "Feladatok amelyek az agent végrehajtására várnak.", "agent": "agent", "resume": "Folytatás", "stats": { "waiting_on_deps_count": "Függőségekre vár", "completed_count": "Elvégzett feladatok", "worker_count": "Szabad", "running_count": "Fut", "pending_count": "Függőben" }, "task_waiting_on_deps": "A feladat a függőségekre vár", "waiting_for": "várakozás a" }, "agents": { "capacity": { "capacity": "Kapacitás", "desc": "Az agent által maximum párhuzamosan futtatható pipelineok száma.", "badge": "kapacitás" }, "name": { "name": "Név", "placeholder": "Az Agent neve" }, "delete_confirm": "Biztos, hogy törölni akarod ezt az agentet? Mostantól, nem fog tudni kommunikálni a szerverrel.", "never": "Soha", "last_contact": "Legutóbbi kommunikáció", "edit_agent": "Agent szerkesztése", "agents": "Agentek", "none": "Még nincsenek agentek.", "id": "ID", "add": "Agent hozzáadása", "show": "Agentek mutatása", "saved": "Agent elmentve", "delete_agent": "Agent törlése", "custom_labels": { "custom_labels": "Egyedi bélyegek", "desc": "Az egyedi bélyegek az agent adminisztrátorja állítja be az agent elindításánál." }, "desc": "Agentek amelyek ehhez a szerverhez vannak regisztrálva.", "save": "Agent mentése", "created": "Agent létrehozva", "deleted": "Agent törölve", "no_schedule": { "name": "Agent kikapcsolása", "placeholder": "Az agent megakadályozása, hogy új feladatokat vegyen fel" }, "token": "Token", "platform": { "platform": "Platform", "badge": "platform" }, "backend": { "backend": "Backend", "badge": "backend" }, "version": "Verzió", "org": { "badge": "Org" } }, "repos": { "view": "Repository megnézése", "repos": "Repositoryk", "desc": "Repositoryk amik aktiválva vagy aktiválva voltak ezen a szerveren.", "none": "Még nincsenek repositoryk.", "settings": "Repository beállítások", "disabled": "Kikapcsolva", "repair": { "repair": "Minden javítása", "success": "Repositoryk javítva" } }, "not_allowed": "Nincs hozzá", "secrets": { "desc": "A globális titkos változók az összes repository összes pipelinejában használhatóak.", "warning": "Ezek a titkos változók minden felhasználónak elérhetőek." }, "registries": { "warning": "Ezek a regisztry hozzáférési adatok elérhetőek az összes felhasználónak.", "desc": "Lehetőség van globális regisztry hozzáférési adatok hozzáadására,hogy minden pipelinenak hozzáférése legyen privát Docker környezet imagekhez." } } }, "cancel": "Mégse", "login": "Bejelentkezés", "internal_error": "Valamilyen belső hiba történt", "info": "Információ", "registration_closed": "A regisztráció zárva", "repositories": "Repository-k", "documentation_for": "{topic} dokumentációja", "docs": "Dokumentáció", "repos": "Repók", "api": "API", "pipeline_feed": "Pipeline felület", "empty_list": "Nem található {entity}!", "not_found": { "not_found": "Hoppá 404, vagy valamit elrontottunk vagy te írtál el valamit :-/", "back_home": "Vissza a kezdőlapra" }, "secrets": { "save": "Titkos változó mentése", "show": "Titkos változók mutatása", "add": "Titkos változó hozzáadása", "name": "Név", "value": "Érték", "delete": "Titkos változó törlése", "events": { "events": "A következő eseményeknél elérhető", "warning": "Ha a Pull Requesteknek is engedélyt adsz, akkor esély van rá, hogy a rosszakarók ellophassák a titkos változóid értékeit egy rosszindulatú Pull Requestel." }, "edit": "Titkos változó szerkesztése", "plugins": { "images": "Csak a következő pluginoknak elérhető", "desc": "Azon pluginok listája amelyeknél ez a titkos változó elérhető. Hagyd üresen ha minden pluginnak és normális lépésnek elérhető legyen." }, "secrets": "Titkos változók", "deleted": "Titkos változó törölve", "delete_confirm": "Biztos, hogy törölni akarod ezt a titkos változót?", "saved": "Titkos változó elmentve", "desc": "A titkos változók minden a repositoryn futó pipelineban elérhetőek.", "none": "Még nincsenek titkos változók.", "created": "Titkos változó létrehozva" }, "default": "alapértelmezett", "no_search_results": "Nincs találat", "all_repositories": "Összes Repository", "org_access_denied": "Nincs hozzáférésed ehhez az organizáció megnézéséhez", "user": { "settings": { "general": { "theme": { "auto": "Automatikus", "light": "Világos", "theme": "Téma", "dark": "Sötét" }, "general": "Általános", "language": "Nyelv" }, "cli_and_api": { "download_cli": "CLI letöltése", "reset_token": "Token visszaállítása", "swagger_ui": "Swagger felület", "token": "Saját Hozzáférési Token", "api_usage": "Példa az API használatára", "desc": "Saját Hozzáférési Token (PAT), CLI és API használat", "cli_usage": "Példa a CLI használatára", "cli_and_api": "CLI & API" }, "registries": { "desc": "Lehetőség van felhasználói regisztry hozzáférési adatok hozzáadására,hogy minden saját pipelinenak hozzáférése legyen privát Docker környezet imagekhez." }, "agents": { "desc": "Agentek amelyek a fiókod repositoryjaihoz vannak regisztrálva." }, "settings": "Felhasználói beállítások", "secrets": { "desc": "A felhasználó titkos változójai minden a felhasználó tulajdonában álló repositoryn futó pipelinejaiban felhasználható." } } }, "invalid_state": "Az OAuth helyzete érvénytelen", "registries": { "add": "Regisztry hozzáadása", "delete_confirm": "Biztos, hogy törölni akarod ezt a regisztryt?", "created": "Regisztry hozzáférési adatok létrehozva", "saved": "Regisztry hozzáférési adatok elmentve", "deleted": "Regisztry hozzáférési adatok törölve", "credentials": "Registry hozzáférési adatok", "desc": "Lehetőség van regisztry hozzáférési adatok hozzáadására,hogy minden pipelinenak hozzáférése legyen privát Docker környezet imagekhez.", "none": "Nincsenek még regisztry hozzáférési adatok.", "address": { "address": "Cím", "desc": "Regisztry Cím (pl.: docker.io)" }, "show": "Regisztryk mutatása", "registries": "Registryk", "save": "Regisztry mentése", "view": "Regisztry megnézése", "edit": "Regisztry szerkesztése", "delete": "Regisztry törlése" }, "require_approval": { "require_approval_for": "Engedélyezési feltételek", "desc": "Előzz meg rosszindulatú pipelineok lefutását, amelyek titkos változókat fedhetnek fel vagy káros feladatokat futtathatnak, azáltal, hogy jóváhagyod őket a végrehajtás előtt.", "none_desc": "Minden esemény előidéz pipelineokat, beleértve a Pull Requesteket. Ez a beállítás veszélyes lehet és csak privát munkamenetekhez ajánlott.", "forks": "Pull Request egy forkolt repositoryból", "pull_requests": "Mindegyik Pull Request", "none": "Nincs", "all_events": "Az összes esemény a kódplatformtól", "allowed_users": { "desc": "Pipelineok amelyeket a következő felhasználók hoztak létre, soha nem szükséges hozzájuk engedély.", "allowed_users": "Engedélyezett felhasználók" } }, "global_level_secret": "globális titkos változó", "org": { "settings": { "agents": { "desc": "Regisztrált agentek ehhez az organizációhoz." }, "not_allowed": "Nincs jogosultságod ennek az organizáció beállításaihoz", "secrets": { "desc": "Az organizáció titkos változói elérhetőek az organizáció összes repositoryhoz tartózó pipelinejainak." }, "registries": { "desc": "Az organizáció hozzáférési adatai tárolója hozzáadható, hogy privát Docker image-eket használhassanak az organizáció összes pipeline-jában." } } }, "welcome": "Üdvözöllek a Woodpeckerben", "running_version": "A Woodpecker {0}-t futtatod", "oauth_error": "Hiba történt az OAuth szolgáltatóval való kommunikálás közben", "login_with": "Belépés {forge}-al", "errors": { "not_found": "A szerver nem találja a kért objektumot" }, "time": { "not_started": "Nem indult még el", "just_now": "Most éppen" }, "update_woodpecker": "Kérlek frissítsd a Woodpeckered {0}-ra", "org_level_secret": "organizáció titkos változó", "login_to_cli": "Belépés a CLIbe", "login_to_cli_description": "Ha folytatod, be leszel léptetve a CLIbe.", "abort": "Megszakítás", "cli_login_success": "Sikeres belépés a CLIbe", "cli_login_failed": "Sikertelen belépés a CLIbe", "cli_login_denied": "Belépés a CLIbe megtagadva", "return_to_cli": "Mostmár bezárhatod ezt az ablakot és visszaléphetsz a CLIbe.", "settings": "Beállítások", "access_denied": "Nincs hozzáférésed ehhez a munkamenethez" } ================================================ FILE: web/src/assets/locales/id.json ================================================ { "admin": { "settings": { "agents": { "add": "Tambahkan agen", "agents": "Agen", "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "badge": "kapasitas", "capacity": "Kapasitas", "desc": "Jumlah maksimal jalur pipa paralel yang dijalankan oleh agen ini." }, "created": "Agen dibuat", "delete_agent": "Hapus agen", "delete_confirm": "Apakah Anda ingin menghapus agen ini. Itu tidak akan terhubung lagi ke server.", "deleted": "Agen dihapus", "desc": "Agen terdaftar untuk server", "edit_agent": "Sunting agen", "id": "ID", "last_contact": "Hubungan terakhir", "name": { "name": "Nama", "placeholder": "Nama agen" }, "never": "Tidak pernah", "no_schedule": { "name": "Nonaktifkan agen", "placeholder": "Hentikan agen untuk mengambil tugas baru" }, "none": "Belum ada agen.", "platform": { "badge": "platform", "platform": "Platform" }, "save": "Simpan agen", "saved": "Agen disimpan", "show": "Tampilkan agen", "token": "Token", "version": "Versi" }, "not_allowed": "Anda tidak diperbolehkan untuk mengakses pengaturan peladen", "orgs": { "delete_confirm": "Apakah Anda benar-benar ingin menghapus organisasi ini? Ini juga akan menghapus semua repositori yang dimiliki oleh organisasi ini.", "delete_org": "Hapus organisasi", "deleted": "Organisasi dihapus", "desc": "Organisasi yang memiliki repositori di server ini", "none": "Belum ada organisasi.", "org_settings": "Pengaturan organisasi", "orgs": "Organisasi", "view": "Tampilkan organisasi" }, "queue": { "agent": "agen", "desc": "Tugas tertunda yang akan dilakukan oleh agen", "pause": "Jeda", "paused": "Antrean dijeda", "queue": "Antrean", "resume": "Lanjutkan", "resumed": "Antrean dilanjutkan", "stats": { "completed_count": "Tugas yang Selesai", "pending_count": "Menunggu", "running_count": "Berjalan", "waiting_on_deps_count": "Menunggu ketergantungan", "worker_count": "Bebas" }, "task_pending": "Tugas tertunda", "task_running": "Tugas sedang berjalan", "task_waiting_on_deps": "Tugas sedang menunggu ketergantungan", "tasks": "Tugas", "waiting_for": "menunggu" }, "repos": { "desc": "Repositori yang sudah atau sebelumnya diaktifkan di server ini", "disabled": "Dinonaktifkan", "none": "Belum ada repositori.", "repair": { "repair": "Perbaiki semua", "success": "Repositori diperbaiki" }, "repos": "Repositori", "settings": "Pengaturan repositori", "view": "Tampilkan repositori" }, "secrets": { "add": "Tambahkan rahasia", "created": "Rahasia global dibuat", "deleted": "Rahasia global dihapus", "desc": "Rahasia global dapat diberikan untuk semua langkah jalur pipa repositori individu saat berjalan sebagai variabel lingkungan.", "events": { "events": "Tersedia pada peristiwa berikut", "pr_warning": "Mohon berhati-hati dengan pilihan ini karena seseorang dapat membuat sebuah permintaan penarikan yang dapat mengekspos rahasia Anda." }, "images": { "desc": "Daftar citra di mana rahasia ini tersedia, kosongkan untuk memperbolehkan semua citra", "images": "Tersedia untuk citra berikut" }, "name": "Nama", "none": "Belum ada rahasia global.", "plugins_only": "Hanya tersedia untuk plugin", "save": "Simpan rahasia", "saved": "Rahasia global disimpan", "secrets": "Rahasia", "show": "Tampilkan rahasia", "value": "Nilai", "warning": "Rahasia ini akan tersedia untuk pengguna peladen." }, "settings": "Pengaturan", "users": { "add": "Tambahkan pengguna", "admin": { "admin": "Admin", "placeholder": "Pengguna adalah admin" }, "avatar_url": "URL Avatar", "cancel": "Batal", "created": "Pengguna dibuat", "delete_confirm": "Apakah Anda ingin menghapus pengguna ini? Ini juga akan menghapus semua repositori yang dimiliki oleh pengguna ini.", "delete_user": "Hapus pengguna", "deleted": "Pengguna dihapus", "desc": "Pengguna terdaftar untuk server ini", "edit_user": "Sunting pengguna", "email": "Surel", "login": "Masuk", "none": "Belum ada pengguna.", "save": "Simpan pengguna", "saved": "Pengguna disimpan", "show": "Tampilkan pengguna", "users": "Pengguna" } } }, "api": "API", "back": "Kembali", "cancel": "Batal", "default": "bawaan", "docs": "Dokumentasi", "documentation_for": "Dokumentasi untuk \"{topic}\"", "empty_list": "{entity} tidak ditemukan!", "errors": { "not_found": "Server tidak dapat mencari objek yang diminta" }, "global_level_secret": "rahasia global", "info": "Info", "login": "Masuk", "logout": "Keluar", "not_found": { "back_home": "Kembali ke beranda", "not_found": "Aduh 404, mungkin kami membuat sesuatu rusak atau Anda salah ketik :-/" }, "org": { "settings": { "not_allowed": "Anda tidak diperbolehkan untuk mengakses pengaturan organisasi ini", "secrets": { "add": "Tambahkan rahasia", "created": "Rahasia organisasi dibuat", "deleted": "Rahasia organisasi dihapus", "desc": "Rahasia organisasi dapat diberikan ke semua langkah jalur pipa repositori organisasi individu saat berjalan sebagai variabel lingkungan.", "events": { "events": "Tersedia pada peristiwa berikut", "pr_warning": "Mohon berhati-hati dengan pilihan ini karena seseorang dapat membuat sebuah permintaan penarikan yang dapat mengekspos rahasia Anda." }, "images": { "desc": "Daftar citra di mana rahasia ini tersedia, kosongkan untuk memperbolehkan semua citra", "images": "Tersedia untuk citra berikut" }, "name": "Nama", "none": "Belum ada rahasia organisasi.", "plugins_only": "Hanya tersedia untuk plugin", "save": "Simpan rahasia", "saved": "Rahasia organisasi disimpan", "secrets": "Rahasia", "show": "Tampilkan rahasia", "value": "Nilai" }, "settings": "Pengaturan" } }, "org_level_secret": "rahasia organisasi", "password": "Kata sandi", "pipeline_feed": "Umpan jalur pipa", "repo": { "activity": "Aktivitas", "add": "Tambahkan repositori", "branches": "Cabang", "deploy_pipeline": { "enter_target": "Lingkungan sasaran peluncuran", "title": "Picu perisitiwa peluncuran untuk jalur pipa #{pipelineId}", "trigger": "Luncurkan", "variables": { "add": "Tambahkan variabel", "desc": "Tetapkan variabel tambahan untuk digunakan dalam jalur pipa Anda. Variabel dengan nama yang sama akan ditimpa.", "name": "Nama variabel", "title": "Variabel jalur pipa tambahan", "value": "Nilai variabel" } }, "enable": { "disabled": "Nonaktif", "enable": "Aktifkan", "enabled": "Sudah diaktifkan", "list_reloaded": "Daftar repositori dimuat ulang", "reload": "Muat ulang repositori", "success": "Repositori diaktifkan" }, "manual_pipeline": { "select_branch": "Pilih cabang", "title": "Picu sebuah jalur pipa", "trigger": "Jalankan jalur pipa", "variables": { "add": "Tambahkan variabel", "desc": "Tetapkan variabel tambahan untuk digunakan dalam jalur pipa Anda. Variabel dengan nama yang sama akan ditimpa.", "name": "Nama variabel", "title": "Variabel jalur pipa tambahan", "value": "Nilai variabel" } }, "not_allowed": "Anda tidak diperbolehkan untuk mengakses repositori ini", "open_in_forge": "Buka Repositori dalam Sistem Kendali Versi", "pipeline": { "actions": { "cancel": "Batalkan", "cancel_success": "Jalur pipa dibatalkan", "canceled": "Langkah ini telah dibatalkan.", "deploy": "Luncurkan", "log_auto_scroll": "Gulir ke bawah secara otomatis", "log_auto_scroll_off": "Matikan pengguliran otomatis", "log_download": "Unduh", "restart": "Mulai ulang", "restart_success": "Jalur pipa dimulai ulang" }, "config": "Konfigurasi", "errors": "Kesalahan ({count})", "event": { "cron": "Kron", "deploy": "Luncur", "manual": "Manual", "pr": "Permintaan Penarikan", "push": "Dorongan", "tag": "Tag" }, "exit_code": "kode keluar {exitCode}", "files": "Berkas yang diubah ({files})", "loading": "Memuat…", "log_download_error": "Terjadi sebuah kesalahan saat mengunduh berkas catatan", "log_title": "Catatan Langkah", "no_files": "Tidak ada berkas yang telah diubah.", "no_pipeline_steps": "Tidak ada langkah jalur pipa yang tersedia!", "no_pipelines": "Belum ada jalur pipa yang dimulai.", "pipeline": "Jalur pipa #{pipelineId}", "pipelines_for": "Jalur pipa untuk cabang \"{branch}\"", "pipelines_for_pr": "Jalur pipa untuk permintaan penarikan #{index}", "protected": { "approve": "Setujui", "approve_success": "Jalur pipa disetujui", "awaits": "Jalur pipa ini menunggu untuk disetujui oleh seorang pemelihara!", "decline": "Tolak", "decline_success": "Jalur pipa ditolak", "declined": "Jalur pipa ini telah ditolak!", "review": "Tinjau perubahan" }, "show_errors": "Tampilkan kesalahan", "status": { "blocked": "diblokir", "declined": "ditolak", "error": "terjadi kesalahan", "failure": "gagal", "killed": "dibatalkan", "pending": "menunggu", "running": "berjalan", "skipped": "dilewati", "started": "dimulai", "status": "Status: {status}", "success": "berhasil" }, "step_not_started": "Langkah ini belum dijalankan.", "tasks": "Tugas", "warnings": "Peringatan ({count})", "we_got_some_errors": "Aduh, kami mendapatkan beberapa kesalahan!" }, "pull_requests": "Permintaan penarikan", "settings": { "actions": { "actions": "Tindakan", "delete": { "confirm": "Semua data akan hilang setelah tindakan ini!!!\n\nApakah Anda benar-benar ingin melakukan ini?", "delete": "Hapus repositori", "success": "Repositori dihapus" }, "disable": { "disable": "Nonaktifkan repositori", "success": "Repositori dinonaktifkan" }, "enable": { "enable": "Aktifkan repositori", "success": "Repositori diaktifkan" }, "repair": { "repair": "Perbaiki repositori", "success": "Repositori diperbaiki" } }, "badge": { "badge": "Lencana", "branch": "Cabang", "type": "Sintaks", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL" }, "crons": { "add": "Tambahkan cron", "branch": { "placeholder": "Cabang (menggunakan cabang bawaan jika kosong)", "title": "Cabang" }, "created": "Kron dibuat", "crons": "Cron", "delete": "Hapus cron", "deleted": "Kron dihapus", "desc": "Pekerja cron dapat digunakan untuk memicu jalur pipa pada waktu yang ditentukan.", "edit": "Sunting cron", "name": { "name": "Nama", "placeholder": "Nama pekerja cron" }, "next_exec": "Eksekusi berikutnya", "none": "Belum ada cron.", "not_executed_yet": "Belum dieksekusi", "run": "Jalankan sekarang", "save": "Simpan cron", "saved": "Kron disimpan", "schedule": { "placeholder": "Jadwal", "title": "Jadwal (berdasarkan UTC)" }, "show": "Tampilkan cron" }, "general": { "allow_pr": { "allow": "Perbolehkan Permintaan Penarikan", "desc": "Jalur pipa dapat berjalan pada permintaan penarikan." }, "cancel_prev": { "cancel": "Batalkan jalur pipa sebelumnya", "desc": "Aktifkan untuk membatalkan jalur pipa yang menunggu dan yang berjalan dari peristiwa dan konteks yang sama sebelum memulai picuan yang baru." }, "general": "Umum", "netrc_only_trusted": { "desc": "Hanya masukkan kredensial netrc ke kontainer yang terpercaya (disarankan).", "netrc_only_trusted": "Hanya masukkan kredensial netrc ke kontainer yang terpercaya" }, "pipeline_path": { "default": "Secara bawaan: .woodpecker/*.{'{yaml,yml}'} → .woodpecker.yaml → .woodpecker.yml", "desc": "Jalur ke konfigurasi jalur pipa Anda (misalnya {0}). Folder seharusnya berakhir dengan sebuah {1}.", "desc_path_example": "jalur/saya/", "path": "Jalur pipa" }, "project": "Pengaturan proyek", "protected": { "desc": "Setiap jalur pipa harus disetujui sebelum dijalankan.", "protected": "Dilindungi" }, "save": "Simpan pengaturan", "success": "Pengaturan repositori diperbarui", "timeout": { "minutes": "menit", "timeout": "Waktu habis" }, "trusted": { "desc": "Kontainer jalur pipa dasar mendapatkan akses ke kemampuan yang ditingkatkan seperti memasang volume.", "trusted": "Dipercayai" }, "visibility": { "internal": { "desc": "Hanya pengguna yang terotentikasi dengan instansi Woodpecker dapat melihat proyek ini.", "internal": "Internal" }, "private": { "desc": "Hanya Anda dan pemilik repositori lainnya dapat melihat proyek ini.", "private": "Pribadi" }, "public": { "desc": "Setiap pengguna dapat melihat proyek Anda tanpa harus masuk.", "public": "Publik" }, "visibility": "Keterlihatan proyek" } }, "not_allowed": "Anda tidak diperbolehkan untuk mengakses pengaturan repositori ini", "registries": { "add": "Tambahkan registri", "address": { "address": "Alamat", "placeholder": "Alamat registri (mis. docker.io)" }, "created": "Kredensial registri dibuat", "credentials": "Kredensial registri", "delete": "Hapus registri", "deleted": "Kredensial registri dihapus", "desc": "Kredensial registri dapat ditambahkan untuk menggunakan citra pribadi untuk jalur pipa Anda.", "edit": "Sunting registri", "none": "Belum ada kredensial registri.", "registries": "Registri", "save": "Simpan registri", "saved": "Kredensial registri disimpan", "show": "Tampilkan registri" }, "secrets": { "add": "Tambahkan rahasia", "created": "Rahasia dibuat", "delete": "Hapus rahasia", "delete_confirm": "Apakah Anda ingin menghapus rahasia ini?", "deleted": "Rahasia dihapus", "desc": "Rahasia dapat diberikan ke langkah jalur pipa individu saat dijalankan sebagai variabel lingkungan.", "edit": "Sunting rahasia", "events": { "events": "Tersedia pada peristiwa berikut", "pr_warning": "Mohon berhati-hati dengan pilihan ini karena seseorang dapat membuat sebuah permintaan penarikan yang dapat mengekspos rahasia Anda." }, "images": { "desc": "Daftar citra di mana rahasia ini tersedia, kosongkan untuk memperbolehkan semua citra", "images": "Tersedia untuk citra berikut" }, "name": "Nama", "none": "Belum ada rahasia.", "plugins_only": "Hanya tersedia untuk plugin", "save": "Simpan rahasia", "saved": "Rahasia disimpan", "secrets": "Rahasia", "show": "Tampilkan rahasia", "value": "Nilai" }, "settings": "Pengaturan" }, "user_none": "Organisasi/pengguna belum memiliki proyek apa pun." }, "repos": "Repo", "repositories": "Repositori", "running_version": "Anda sedang menjalankan Woodpecker {0}", "search": "Cari…", "time": { "days_short": "h", "hours_short": "j", "min_short": "mnt", "not_started": "belum dimulai", "sec_short": "dtk", "template": "BBB H, TTTT, JJ:mm z", "weeks_short": "m" }, "unknown_error": "Terjadi sebuah kesalahan yang tidak diketahui", "update_woodpecker": "Mohon tingkatkan server Woodpecker Anda ke {0}", "url": "URL", "user": { "access_denied": "Anda tidak diperbolehkan untuk masuk", "internal_error": "Terjadi beberapa kesalahan internal", "oauth_error": "Terjadi kesalahan saat mengotentikasi dengan penyedia OAuth", "settings": { "api": { "api": "API", "api_usage": "Contoh Penggunaan API", "cli_usage": "Contoh Penggunaan CLI", "desc": "Penggunaan Token Akses Pribadi dan API", "dl_cli": "Unduh CLI", "reset_token": "atur ulang token", "shell_setup": "Penyiapan shell", "shell_setup_before": "lakukan tahap penyiapan shell sebelumnya", "swagger_ui": "UI Swagger", "token": "Token Akses Pribadi" }, "general": { "general": "Umum", "language": "Bahasa", "theme": { "auto": "Otomatis", "dark": "Gelap", "light": "Terang", "theme": "Tema" } }, "secrets": { "add": "Tambahkan rahasia", "created": "Rahasia pengguna dibuat", "deleted": "Rahasia pengguna dihapus", "desc": "Rahasia pengguna dapat diteruskan ke semua jalur pipa individu di semua repositori pengguna saat dijalankan sebagai variabel lingkungan.", "events": { "events": "Tersedia di peristiwa berikut", "pr_warning": "Harap berhati-hati dengan opsi ini karena seseorang dapat mengirimkan permintaan penarikan berbahaya yang dapat mengekspos rahasia Anda." }, "images": { "desc": "Daftar citra di mana rahasia ini tersedia, tinggalkan kosong untuk memperbolehkan semua citra", "images": "Tersedia untuk citra berikut" }, "name": "Nama", "none": "Belum ada rahasia pengguna.", "plugins_only": "Hanya tersedia untuk plugin", "save": "Simpan rahasia", "saved": "Rahasia pengguna disimpan", "secrets": "Rahasia", "show": "Tampilkan rahasia", "value": "Nilai" }, "settings": "Pengaturan Pengguna" } }, "username": "Nama pengguna", "welcome": "Selamat datang di Woodpecker" } ================================================ FILE: web/src/assets/locales/it.json ================================================ { "admin": { "settings": { "queue": { "agent": "agente", "stats": { "completed_count": "Attività Completate", "worker_count": "Liberi", "running_count": "In esecuzione", "waiting_on_deps_count": "In attesa delle dipendenze", "pending_count": "In attesa" }, "waiting_for": "in attesa di", "pause": "Sospendi", "resume": "Riprendi", "resumed": "Coda ripresa", "paused": "Coda sospesa", "tasks": "Attività", "task_running": "Attività in esecuzione", "task_waiting_on_deps": "Attività in attesa delle dipendenze", "desc": "Attività in attesa di essere eseguiti dagli agenti.", "queue": "Coda", "task_pending": "Attività in attesa" }, "users": { "add": "Aggiungi utente", "admin": { "admin": "Amministatore", "placeholder": "L'utente è un amministratore" }, "created": "Utente creato", "delete_confirm": "Vuoi davvero eliminare questo utente? Ciò eliminerà anche tutti i repository di proprietà di questo utente.", "delete_user": "Elimina utente", "deleted": "Utente eliminato", "edit_user": "Modifica utente", "none": "Ancora nessun utente.", "saved": "Utente salvato", "show": "Mostra utenti", "users": "Utenti", "desc": "Utenti registrati su questo server.", "login": "Accesso", "email": "E-mail", "avatar_url": "URL Avatar", "cancel": "Annulla", "save": "Salva utente" }, "agents": { "agents": "Agenti", "desc": "Agenti registrati su questo server.", "none": "Ancora nessun agente.", "id": "ID", "add": "Aggiungi agente", "save": "Salva agente", "show": "Mostra agenti", "created": "Agente creato", "saved": "Agente salvato", "deleted": "Agente eliminato", "name": { "name": "Nome", "placeholder": "Nome dell'agente" }, "no_schedule": { "name": "Disabilita agente", "placeholder": "Impedisci all'agente di accettare nuove attività" }, "token": "Token", "platform": { "platform": "Piattaforma", "badge": "piattaforma" }, "version": "Versione", "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "capacity": "Capacità", "desc": "Il numero massimo di pipeline parallele eseguite da questo agente.", "badge": "capacità" }, "last_contact": "Ultimo contatto", "never": "Mai", "delete_confirm": "Vuoi davvero eliminare questo agente? Non sarà più in grado di connettersi al server.", "edit_agent": "Modifica agente", "delete_agent": "Elimina agente", "org": { "badge": "org" }, "custom_labels": { "custom_labels": "Etichette Personalizzate", "desc": "Le etichette personalizzate impostate dall'amministratore dell'agente all'avvio dell'agente." } }, "orgs": { "desc": "Organizzazioni che possiedono repository su questo server.", "none": "Ancora nessuna organizzazione.", "delete_org": "Elimina organizzazione", "deleted": "Organizzazione eliminata", "org_settings": "Impostazioni organizzazione", "delete_confirm": "Vuoi davvero eliminare questa organizzazione? Ciò eliminerà anche tutti i repository di proprietà di questa organizzazione.", "view": "Mostra organizzazione", "orgs": "Organizzazioni" }, "secrets": { "warning": "Questi segreti sono disponibili per tutti gli utenti.", "desc": "I segreti globali possono essere utilizzati nelle pipeline di tutti i repository." }, "repos": { "settings": "Impostazioni repository", "repos": "Repository", "desc": "Repository che sono o sono stati attivati su questo server.", "none": "Ancora nessun repository.", "view": "Mostra repository", "disabled": "Disabilitato", "repair": { "repair": "Ripara tutti", "success": "Repository riparati" } }, "not_allowed": "Non hai il permesso di accedere alle impostazioni del server", "registries": { "desc": "È possibile aggiungere credenziali di registro globale per utilizzare immagini private per tutte le pipeline.", "warning": "Queste credenziali di registro sono disponibili per tutti gli utenti." } } }, "back": "Indietro", "cancel": "Annulla", "docs": "Documentazione", "documentation_for": "Documentazione per \"{topic}\"", "errors": { "not_found": "Il server non è riuscito a trovare l'oggetto richiesto" }, "login": "Accedi", "logout": "Esci", "not_found": { "back_home": "Torna alla pagina principale", "not_found": "Diamine 404, o qualcosa si è rotto o hai scritto male qualcosa :-/" }, "password": "Password", "pipeline_feed": "Dettagli pipeline", "repo": { "activity": "Attività", "add": "Aggiungi repository", "branches": "Rami", "deploy_pipeline": { "enter_target": "Ambiente di destinazione per il deployment", "title": "Attiva un deployment per la pipeline corrente #{pipelineId}", "trigger": "Distribuzione", "variables": { "add": "Aggiungi variabile", "desc": "Specifica variabili addizionali da usare nella pipeline. Variabili con lo stesso nome saranno sovrascritte.", "name": "Nome variabile", "title": "Variabili addizionali della pipeline", "value": "Valore variabile", "delete": "Elimina variabile" }, "enter_task": "Attività di distribuzione" }, "enable": { "enable": "Abilita", "enabled": "Già abilitato", "list_reloaded": "Elenco dei repository ricaricato", "reload": "Ricarica i repository", "success": "Repository abilitato", "disabled": "Disabilitato" }, "manual_pipeline": { "select_branch": "Seleziona ramo", "title": "Avvia un'esecuzione manuale della pipeline", "trigger": "Esegui pipeline", "variables": { "add": "Aggiungi variabile", "desc": "Specifica variabili addizionali da usare nella pipeline. Variabili con lo stesso nome saranno sovrascritte.", "name": "Nome variabile", "title": "Variabili aggiuntive per la pipeline", "value": "Valore variabile", "delete": "Elimina variabile" }, "show_pipelines": "Mostra pipeline" }, "not_allowed": "Non hai il permesso di accedere a questo repository", "open_in_forge": "Apri repository sul forge", "pipeline": { "actions": { "cancel": "Annulla", "log_delete": "Elimina", "log_auto_scroll": "Abilita scorrimento automatico", "log_auto_scroll_off": "Disattiva scorrimento automatico", "restart": "Riavvia", "canceled": "Questo step è stato annullato.", "cancel_success": "Pipeline annullata", "restart_success": "Pipeline riavviata", "log_download": "Scarica", "deploy": "Distribuzione" }, "no_pipelines": "Non è ancora stata attivata alcuna pipeline.", "pipelines_for": "Pipeline per ramo \"{branch}\"", "files": "File modificati", "no_pipeline_steps": "Nessuno step disponibile nella pipeline!", "step_not_started": "Questo step non è ancora iniziato.", "config": "Configurazione", "tasks": "Attività", "log_delete_confirm": "Vuoi davvero eliminare i registri attività di step?", "errors": "Errori", "protected": { "awaits": "Questa pipeline è in attesa di approvazione da parte di un manutentore!", "approve": "Approva", "decline": "Rifiuta", "declined": "Questa pipeline è stata rifiutata!", "approve_success": "Pipeline approvata", "decline_success": "Pipeline rifiutata" }, "log_delete_error": "Si è verificato un errore durante l'eliminazione dei registri attività di step", "status": { "failure": "fallita", "killed": "terminata", "status": "Stato: {status}", "pending": "in attesa", "running": "in esecuzione", "started": "avviata", "blocked": "bloccata", "skipped": "saltata", "success": "completata", "declined": "rifiutata", "error": "errore" }, "warnings": "Avvertenze", "show_errors": "Mostra errori", "we_got_some_errors": "Oh no, si è verificato un errore!", "event": { "tag": "Tag", "cron": "Attività pianificata", "manual": "Manuale", "release": "Rilascio", "push": "Invia", "pr": "Richiesta di Modifica", "deploy": "Distribuzione", "pr_closed": "Richiesta di Modifica unita/chiusa" }, "exit_code": "Codice di Uscita {exitCode}", "loading": "Caricamento…", "pipeline": "Pipeline #{pipelineId}", "log_download_error": "Si è verificato un errore durante il download del registro attività", "log_title": "Registro Attività di Step", "pipelines_for_pr": "Pipeline per richiesta di modifica #{index}", "duration": "Durata pipeline", "created": "Creata: {created}", "no_logs": "Nessun registro", "debug": { "title": "Diagnostica", "download_metadata": "Download metadati", "metadata_download_error": "Errore durante il download dei metadati", "metadata_download_successful": "Metadati scaricati correttamente", "no_permission": "Non hai il permesso di accedere alle informazioni di diagnostica", "metadata_exec_title": "Riesegui pipeline localmente", "metadata_exec_desc": "Scarica i metadati di questa pipeline per eseguirla in locale. In questo modo è possibile risolvere i problemi e testare le modifiche prima di eseguirne il commit. 'woodpecker-cli' deve essere installato localmente, alla stessa versione del Server Woodpecker." } }, "pull_requests": "Richieste di modifica", "settings": { "actions": { "actions": "Azioni", "repair": { "success": "Repository riparato", "repair": "Ripara repository" }, "delete": { "delete": "Elimina repository", "confirm": "Tutti i dati andranno persi dopo questa azione!\n\nVuoi davvero procedere?", "success": "Repository eliminato" }, "disable": { "disable": "Disabilita repository", "success": "Repository disabilitato" }, "enable": { "enable": "Abilita repository", "success": "Repository abiltato" } }, "crons": { "name": { "name": "Nome", "placeholder": "Nome attività pianificata" }, "next_exec": "Prossima esecuzione", "not_executed_yet": "Non ancora eseguita", "none": "Ancora nessuna attività pianificata.", "branch": { "placeholder": "Ramo (utilizza ramo predefinito se vuoto)", "title": "Ramo" }, "add": "Aggiungi attività pianificata", "save": "Salva attività pianificata", "created": "Attività pianificata creata", "saved": "Attività pianificata salvata", "deleted": "Attività pianificata eliminata", "run": "Esegui ora", "schedule": { "title": "Programma (basato su UTC)", "placeholder": "Programma" }, "edit": "Modifica attività pianificata", "delete": "Elimina attività pianificata", "desc": "Le attività pianificate possono attivare pipeline a intervalli regolari.", "show": "Mostra attività pianificate", "crons": "Attività pianificate" }, "general": { "general": "Generale", "pipeline_path": { "default": "Predefinito: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "path": "Percorso della pipeline", "desc": "Percorso per la configurazione della pipeline (ad esempio {0}). Le cartelle devono terminare con {1}.", "desc_path_example": "mio/percorso/" }, "project": "Impostazioni progetto", "protected": { "protected": "Protetto", "desc": "Ogni pipeline deve essere approvata prima di essere eseguita." }, "save": "Salva impostazioni", "success": "Impostazioni del progetto aggiornate", "timeout": { "minutes": "minuti", "timeout": "Scadenza" }, "visibility": { "internal": { "desc": "Solo gli utenti autenticati dell'istanza Woodpecker possono vedere questo progetto.", "internal": "Interno" }, "private": { "desc": "Solo tu e gli altri proprietari della repository potete vedere questo progetto.", "private": "Privato" }, "public": { "desc": "Ogni utente può vedere il tuo progetto senza aver effettuato l'accesso.", "public": "Pubblico" }, "visibility": "Visibilità progetto" }, "cancel_prev": { "desc": "Gli eventi selezionati annullano le pipeline in attesa e quelle in esecuzione dello stesso evento prima di avviare la successiva.", "cancel": "Annulla pipeline precedenti" }, "trusted": { "desc": "I container eseguiti dalle pipeline ottengono accesso a funzionalità avanzate (come il montaggio di volumi).", "trusted": "Attendibile", "network": { "network": "Rete", "desc": "I container di pipeline ottengono l'accesso ai privilegi di rete, come la modifica del DNS." }, "volumes": { "volumes": "Volumi", "desc": "I container di pipeline possono montare volumi." }, "security": { "security": "Sicurezza", "desc": "I container di pipeline ottengono l'accesso ai privilegi di sicurezza." } }, "allow_pr": { "allow": "Consenti Richieste di Modifica", "desc": "Consenti l'esecuzione di pipeline sulle richieste di modifica." }, "allow_deploy": { "allow": "Consenti Deployment", "desc": "Consenti deployment per le pipeline riuscite. Tutti gli utenti con autorizzazioni push possono attivarli, da usare con cautela." }, "netrc_only_trusted": { "netrc_only_trusted": "Estensioni personalizzate affidabili per clone", "desc": "Estensioni che ottengono accesso alle credenziali netrc che possono essere utilizzate per clonare dal forge o per inviare modifiche." } }, "not_allowed": "Non hai il permesso di accedere alle impostazioni di questo repository", "secrets": { "name": "Nome", "value": "Valore" }, "settings": "Impostazioni", "registries": { "registries": "Registri", "desc": "È possibile aggiungere credenziali di registro per utilizzare immagini private nella pipeline.", "address": { "placeholder": "Indirizzo del Registro (es. docker.io)", "address": "Indirizzo" }, "credentials": "Credenziali di registro", "show": "Mostra registri", "add": "Aggiungi registro", "none": "Ancora nessuna credenziale di registro.", "save": "Salva registro", "created": "Credenziali di registro create", "saved": "Credenziali di registro salvate", "deleted": "Credenziali di registro eliminate", "edit": "Modifica registro", "delete": "Elimina registro" }, "badge": { "type": "Sintassi", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "badge": "Badge", "branch": "Ramo" } }, "user_none": "L'organizzazione/utente non ha ancora alcun progetto", "visibility": { "visibility": "Visibilità progetto", "public": { "public": "Pubblico", "desc": "Chiunque può vedere il tuo progetto senza essere autenticato." }, "private": { "private": "Privato", "desc": "Solo tu e gli altri proprietari del repository possono visualizzare questo progetto." }, "internal": { "internal": "Interno", "desc": "Solo gli utenti autenticati dell'istanza di Woodpecker possono visualizzare questo progetto." } } }, "repos": "Repo", "repositories": "Repository", "search": "Cerca…", "time": { "not_started": "non ancora iniziato", "template": "MMM D, YYYY, HH:mm z", "just_now": "poco fa" }, "unknown_error": "Si è verificato un errore sconosciuto", "url": "URL", "user": { "access_denied": "Non hai il permesso di entrare", "internal_error": "Errore interno", "oauth_error": "Errore durante l'autenticazione con il provider Oauth", "settings": { "secrets": { "desc": "I segreti utente possono essere utilizzati nelle pipeline di tutti i repository di proprietà dell'utente." }, "cli_and_api": { "desc": "Utilizzo del Token di Accesso Personale, CLI e API", "token": "Token di Accesso Personale", "api_usage": "Esempio di utilizzo dell'API", "cli_usage": "Esempio di utilizzo della CLI", "download_cli": "Scarica CLI", "reset_token": "Ripristina token", "swagger_ui": "Interfaccia Swagger", "cli_and_api": "CLI & API" }, "settings": "Impostazioni Utente", "general": { "general": "Generale", "language": "Lingua", "theme": { "theme": "Tema", "light": "Chiaro", "dark": "Scuro", "auto": "Auto" } }, "registries": { "desc": "È possibile aggiungere le credenziali di registro per utilizzare immagini private in tutte le pipeline personali." }, "agents": { "desc": "Agenti registrati nei repository dell'account." } } }, "username": "Nome utente", "welcome": "Benvenuti in Woodpecker", "secrets": { "created": "Segreto creato", "saved": "Segreto salvato", "events": { "events": "Disponibile ai seguenti eventi", "pr_warning": "Si prega di fare attenzione con questa opzione: un malintenzionato potrebbe inviare una richiesta di modifica dannosa, che rivela i tuoi segreti.", "warning": "Esporre segreti nelle richieste di modifica potrebbe consentire a malintenzionati di rubare i tuoi segreti tramite una pull request malevola." }, "value": "Valore", "delete_confirm": "Vuoi davvero eliminare questo segreto?", "none": "Ancora nessun segreto.", "add": "Aggiungi segreto", "save": "Salva segreto", "show": "Mostra segreti", "name": "Nome", "delete": "Elimina segreto", "edit": "Modifica segreto", "deleted": "Segreto eliminato", "images": { "images": "Disponibile per le seguenti immagini", "desc": "Elenco di immagini in cui questo segreto è disponibile, lascia vuoto per consentire a tutte le immagini." }, "secrets": "Segreti", "desc": "I segreti possono essere utilizzati in tutte le pipeline di questo repository.", "plugins": { "images": "Disponibile solo per le seguenti estensioni", "desc": "Elenco delle estensioni in cui questo segreto è disponibile. Vuoto, per consentire a ogni estensione e step normale." } }, "default": "predefinito", "info": "Dettagli", "update_woodpecker": "Aggiorna la tua istanza Woodpecker a {0}", "global_level_secret": "segreto globale", "org_level_secret": "segreto organizzazione", "login_to_cli": "Accedi alla CLI", "abort": "Interrompi", "cli_login_success": "Accesso alla CLI riuscito", "oauth_error": "Errore durante l'autenticazione con il provider OAuth", "internal_error": "Si è verificato un errore interno", "registration_closed": "La registrazione è chiusa", "api": "API", "empty_list": "Nessuna (entità) trovata!", "cli_login_denied": "Accesso alla CLI negato", "return_to_cli": "Ora puoi chiudere questa scheda e tornare alla CLI.", "settings": "Impostazioni", "cli_login_failed": "Accesso alla CLI fallito", "login_to_cli_description": "Se continui, verrai autenticato nella CLI.", "access_denied": "Non hai il permesso di accedere a questa istanza", "invalid_state": "Lo stato OAuth non è valido", "org": { "settings": { "not_allowed": "Non hai il permesso di accedere alle impostazioni di questa organizzazione", "secrets": { "desc": "I segreti dell'organizzazione possono essere utilizzati nelle pipeline di tutti i repository di proprietà dell'organizzazione." }, "registries": { "desc": "È possibile aggiungere le credenziali di registro dell'organizzazione per utilizzare immagini private per tutte le pipeline di un'organizzazione." }, "agents": { "desc": "Agenti registrati per questa organizzazione." } } }, "running_version": "Stai eseguendo Woodpecker {0}", "registries": { "delete_confirm": "Vuoi davvero eliminare questo registro?", "registries": "Registri", "credentials": "Credenziali di registro", "desc": "È possibile aggiungere le credenziali di registro per utilizzare immagini private nelle pipeline.", "none": "Ancora nessune credenziali di registro.", "address": { "address": "Indirizzo", "desc": "Indirizzo di Registro (es. docker.io)" }, "show": "Mostra registri", "save": "Salva registro", "add": "Aggiungi registro", "view": "Mostra registro", "edit": "Modifica registro", "delete": "Elimina registro", "created": "Credenziali di registro create", "saved": "Credenziali di registro salvate", "deleted": "Credenziali di registro eliminate" }, "login_with": "Accedi con {forge}", "all_repositories": "Tutti i repository", "no_search_results": "Nessun risultato trovato", "require_approval": { "forks": "Richieste di modifica dal fork", "desc": "Impedisci alle pipeline dannose di esporre segreti o eseguire attività dannose approvandole prima dell'esecuzione.", "require_approval_for": "Requisiti di approvazione", "none": "Nessuno", "pull_requests": "Tutte le richieste di modifica", "all_events": "Tutti gli eventi del forge", "none_desc": "Ogni evento attiva le pipeline, incluse le richieste di modifica. Questa impostazione può essere pericolosa ed è consigliata solo per le istanze private.", "allowed_users": { "allowed_users": "Utenti autorizzati", "desc": "Pipeline create dagli utenti elencati non richiedono mai approvazione." } }, "org_access_denied": "Non sei autorizzato ad accedere a questa organizzazione" } ================================================ FILE: web/src/assets/locales/lv.json ================================================ { "admin": { "settings": { "agents": { "add": "Pievienot aģentu", "agents": "Aģenti", "backend": { "backend": "Aizmugures sistēma", "badge": "aizmugures sistēma" }, "capacity": { "badge": "paralēlie darbi", "capacity": "Paralēlie darbi", "desc": "Maksimālais aģenta paralēli izpildāmo konvejerdarbu skaits." }, "created": "Aģents izveidots", "delete_agent": "Dzēst aģentu", "delete_confirm": "Vai tiešām vēlaties dzēst šo aģentu? Tam vairs nebūs iespējas savienoties ar serveri.", "deleted": "Aģents izdzēsts", "desc": "Šajā serverī reģistrētie aģenti.", "edit_agent": "Labot aģentu", "id": "ID", "last_contact": "Pēdējā sazināšanās", "name": { "name": "Nosaukums", "placeholder": "Aģenta nosaukums" }, "never": "nekad", "no_schedule": { "name": "Atspējot aģentu", "placeholder": "Apturēt aģentu no jaunu darbu pieņemšanas" }, "none": "Pagaidām nav neviena aģenta.", "platform": { "badge": "platforma", "platform": "Platforma" }, "save": "Saglabāt aģentu", "saved": "Aģents saglabāts", "show": "Parādīt aģentus", "token": "Drošības talons", "version": "Versija" }, "not_allowed": "Nav piekļuves servera iestatījumiem.", "orgs": { "delete_confirm": "Vai patiešām vēlaties dzēst šo organizāciju? Tiks dzēsti arī visi organizācijai piederošie repozitoriji.", "delete_org": "Dzēst organizāciju", "deleted": "Organizācija tika izdzēsta", "desc": "Organizācijas, kurām pieder repozitoriji šajā serverī.", "none": "Pagaidām nav nevienas organizācijas.", "org_settings": "Organizācijas iestatījumi", "orgs": "Organizācijas", "view": "Skatīt organizāciju" }, "queue": { "agent": "aģents", "desc": "Uzdevumi, kuri gaida izpildi", "pause": "Apturēt", "paused": "Rinda ir apturēta", "queue": "Rinda", "resume": "Atsākt", "resumed": "Rindas apstrāde atsākta", "stats": { "completed_count": "Pabeigtie uzdevumi", "pending_count": "Gaida", "running_count": "Izpildās", "waiting_on_deps_count": "Gaida uz atkarībām", "worker_count": "Brīvi" }, "task_pending": "Uzdevums gaida izpildi", "task_running": "Uzdevums tiek izpildīts", "task_waiting_on_deps": "Uzdevums gaida uz atkarībām", "tasks": "Uzdevumi", "waiting_for": "gaida uz" }, "repos": { "desc": "Repozitoriji, kas ir vai ir bijuši iespējoti šajā serverī.", "disabled": "Atspējots", "none": "Pagaidām nav neviena repozitorija.", "repair": { "repair": "Salabot visus", "success": "Repozitoriji salaboti" }, "repos": "Repozitoriji", "settings": "Repozitorija iestatījumi", "view": "Skatīt repozitoriju" }, "secrets": { "add": "Pievienot noslēpumu", "created": "Globālais noslēpums izveidots", "deleted": "Globālais noslēpums dzēsts", "desc": "Noslēpumus var padot visu repozitoriju konvejerdarba soļiem izpildes laikā kā vides mainīgos.", "events": { "events": "Pieejams šādiem notikumiem", "pr_warning": "Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu." }, "images": { "desc": "Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem", "images": "Pieejami šādiem attēliem" }, "name": "Nosaukums", "none": "Pagaidām nav neviena globālā noslēpuma.", "plugins_only": "Pieejams tikai spraudņiem", "save": "Saglabāt noslēpumu", "saved": "Globālais noslēpums saglabāts", "secrets": "Noslēpumi", "show": "Noslēpumu saraksts", "value": "Vērtība", "warning": "Šie noslēpumi būs pieejami visiem lietotājiem." }, "settings": "Iestatījumi", "users": { "add": "Pievienot lietotāju", "admin": { "admin": "Administrators", "placeholder": "Lietotājs ir administrators" }, "avatar_url": "Avatāra URL", "cancel": "Atcelt", "created": "Lietotājs izveidots", "delete_confirm": "Vai patiešām vēlaties dzēst šo lietotāju? Tiks dzēsti arī visi lietotājam piederošie repozitoriji.", "delete_user": "Dzēst lietotāju", "deleted": "Lietotājs izdzēsts", "desc": "Lietotāji, kas ir reģistrēti šajā serverī", "edit_user": "Labot lietotāju", "email": "E-pasta adrese", "login": "Lietotāja vārds", "none": "Pašlaik vēl nav neviena lietotāja.", "save": "Saglabāt lietotāju", "saved": "Lietotāja dati saglabāti", "show": "Parādīt lietotājus", "users": "Lietotāji" }, "registries": { "warning": "Šie repozitorijas pilnvaras būs pieejamas visiem lietotājiem.", "desc": "Globāli repozitoriju pilnvaras var tikt pievienoti pielietošanai privātos attēlos visiem konvejerdarbiem." } } }, "api": "API", "back": "Atpakaļ", "cancel": "Atcelt", "default": "noklusētais", "docs": "Dokumentācija", "documentation_for": "Dokumentācija par \"{topic}\"", "errors": { "not_found": "Nevarēja atrast pieprasīto objektu" }, "info": "Informācija", "login": "Autorizēties", "logout": "Iziet", "not_found": { "back_home": "Uz sākumu", "not_found": "Ak vai, 404, vai nu mēs salauzām kaut ko, vai arī tika atvērta lapa, kas neeksistē :-/" }, "org": { "settings": { "not_allowed": "Nav piekļuves šīs organizācijas iestatījumiem", "secrets": { "add": "Pievienot noslēpumu", "created": "Organizācijas noslēpums izveidots", "deleted": "Organizācijas noslēpums dzēsts", "desc": "Noslēpumus var padot visu organizācijas repozitoriju konvejerdarba soļiem kā vides mainīgos.", "events": { "events": "Pieejams šādiem notikumiem", "pr_warning": "Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu." }, "images": { "desc": "Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem", "images": "Pieejami šādiem attēliem" }, "name": "Nosaukums", "none": "Pagaidām nav neviena organizācijas noslēpuma.", "plugins_only": "Pieejams tikai spraudņiem", "save": "Saglabāt noslēpumu", "saved": "Organizācijas noslēpums saglabāts", "secrets": "Noslēpumi", "show": "Noslēpumu saraksts", "value": "Vērtība" }, "settings": "Iestatījumi", "registries": { "desc": "Organizācijas reģistrijas pilnvaras var tikt pievienoti, lai izmantotu privātas attēlos priekš visiem konvejerdarbiem organizācijā." } } }, "password": "Parole", "pipeline_feed": "Konvejerdarba padeve", "repo": { "activity": "Aktivitāte", "add": "Pievienot repozitoriju", "branches": "Atzari", "deploy_pipeline": { "enter_target": "Mērķa uzstādīšanas vide", "title": "Iniciēt uzstādīšanu šim konvejerdarbam #{pipelineId}", "trigger": "Uzstādīt", "variables": { "add": "Pievienot mainīgo", "desc": "Norādiet papildus mainīgos, ko izmantot konvejerdarbā. Mainīgie ar šādu pašu nosaukumu tiks pārrakstīti.", "name": "Mainīgā nosaukums", "title": "Papildus konvejerdarba mainīgie", "value": "Mainīgā vērtība", "delete": "Dzēst mainīgo" }, "enter_task": "Uzstādīšanas uzdevums" }, "enable": { "disabled": "Atspējots", "enable": "Iespējot", "enabled": "Jau ir iespējots", "list_reloaded": "Repozitoriju sarakts tika pārlādēts", "reload": "Pārlādēt repozitorijus", "success": "Repozitorijs iespējots" }, "manual_pipeline": { "select_branch": "Norādiet atzaru", "title": "Iniciēt manuālu konvejerdarba izpildi", "trigger": "Izpildīt konvejerdarbu", "variables": { "add": "Pievienot", "desc": "Norādiet papildus mainīgos, ko izmantot konvejerdarbā. Mainīgie ar tādu pašu nosaukumu tiks pārrakstīti.", "name": "Mainīgā nosaukums", "title": "Papildus konvejerdarba mainīgie", "value": "Mainīgā vērtība", "delete": "Dzēst mainīgo" }, "show_pipelines": "Rādīt konvejerdarbus" }, "not_allowed": "Nav piekļuves šim repozitorijam", "open_in_forge": "Atvērt repozitoriju iekš forge", "pipeline": { "actions": { "cancel": "Atcelt", "cancel_success": "Konvejerdarbs atcelts", "canceled": "Šis solis tika atcelts.", "deploy": "Uzstādīt", "log_auto_scroll": "Automātiski ritināt", "log_auto_scroll_off": "Atslēgt automātisko ritināšanu", "log_download": "Lejupielādēt", "restart": "Pārstartēt", "restart_success": "Konvejerdarbs pārstartēts", "log_delete": "Dzēst" }, "config": "Konfigurācija", "errors": "Kļūdas ({count})", "event": { "cron": "Plānotais darbs", "deploy": "Uzstādīšana", "manual": "Manuāls", "pr": "Izmaiņu pieprasījums", "push": "Iesūtīšana", "tag": "Tags", "pr_closed": "Izmaiņu pieprasījums apvienots / aizvērts", "release": "Relīze" }, "exit_code": "Iziešanas kods {exitCode}", "files": "Izmainītie faili ({files})", "loading": "Notiek ielāde…", "log_download_error": "Veicot žurnālfaila lejupielādi notika kļūda", "log_title": "Soļa žurnāls", "no_files": "Neviens fails nav mainīts.", "no_pipeline_steps": "Konvejerdarbam nav neviena soļa!", "no_pipelines": "Neviens konvejerdarbs vēl nav uzsākts.", "pipeline": "Konvejerdarbs #{pipelineId}", "pipelines_for": "Konvejerdarbi atzaram \"{branch}\"", "pipelines_for_pr": "Konvejerdarbi izmaiņu pieprasījumam #{index}", "protected": { "approve": "Apstiprināt", "approve_success": "Konvejerdarbs apstiprināts", "awaits": "Šim konvejerdarbam ir nepieciešams apstiprinājums no atbildīgajām personām!", "decline": "Noraidīt", "decline_success": "Konvejerdarbs noraidīts", "declined": "Šis konvejerdarbs tika noraidīts!", "review": "Pārskatiet izmaiņas" }, "show_errors": "Rādīt kļūdas", "status": { "blocked": "bloķēts", "declined": "noraidīts", "error": "kļūda", "failure": "neveiksmīgs", "killed": "apturēts", "pending": "gaida izpildi", "running": "izpildās", "skipped": "izlaists", "started": "uzsākts", "status": "Statuss: {status}", "success": "izpildīts" }, "step_not_started": "Šis solis vēl nav uzsākts.", "tasks": "Uzdevumi", "warnings": "Brīdinājumi ({count})", "we_got_some_errors": "Ak nē, notika kļūda!", "no_logs": "Nav darbību žurnālu", "duration": "Konvejerdarba ilgums", "created": "Izveidots: {created}", "log_delete_confirm": "Vai tiešām vēlaties dzēst šī soļa darbību žurnālus?", "log_delete_error": "Notika kļūda dzēšot šī soļa darbību žurnālus" }, "pull_requests": "Izmaiņu pieprasījumi", "settings": { "actions": { "actions": "Darbības", "delete": { "confirm": "Visi repozitorija dati tiks neatgriezeniski dzēsti!\n\nVai vēlaties turpināt?", "delete": "Dzēst repozitoriju", "success": "Repozitorijs dzēsts" }, "disable": { "disable": "Atspējot repozitoriju", "success": "Repozitorijs atspējots" }, "enable": { "enable": "Iespējot repozitoriju", "success": "Repozitorijs iespējots" }, "repair": { "repair": "Salabot repozitoriju", "success": "Repozitorijs salabots" } }, "badge": { "badge": "Nozīmīte", "branch": "Atzars", "type": "Pieraksta veids", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL" }, "crons": { "add": "Pievienot plānoto darbu", "branch": { "placeholder": "Atzars (atstājiet tukšu, lai izmantotu noklusēto atzaru)", "title": "Atzars" }, "created": "Izveidots plānotais darbs", "crons": "Darbu plānotājs", "delete": "Dzēst darbu plānotāju", "deleted": "Plānotais darbs izdzēsts", "desc": "Darbu plānotājs var tikt izmantots, lai izpildītu konvejerdarbus pēc noteikta grafika.", "edit": "Labot darbu plānotāju", "name": { "name": "Nosaukums", "placeholder": "Plānotā darba nosaukums" }, "next_exec": "Nākošā izpilde", "none": "Nav pievienots neviens plānotais darbs.", "not_executed_yet": "Vēl ne reizi nav izpildīts", "run": "Izpildīt tagad", "save": "Saglabāt plānoto darbu", "saved": "Plānotā darba izmaiņas saglabātas", "schedule": { "placeholder": "Grafiks", "title": "Grafiks (balstīts uz UTC laika joslu)" }, "show": "Parādīt plānotos darbus" }, "general": { "allow_pr": { "allow": "Atļaut izmaiņu pieprasījumiem", "desc": "Ļaut izpildīt konvejerdarbus izmaiņu pieprasījumiem." }, "cancel_prev": { "cancel": "Atcelt iepriekšējos konvejerdarbus", "desc": "Iespējojot šo pazīmi, tiks atcelti visi iepriekšējie konvejerdarbi, kuriem sakrīt notikums un konteksts." }, "general": "Projekts", "netrc_only_trusted": { "desc": "Atļaut izmantot Git autorizāciju tikai uzticamiem konteineriem (ieteicams).", "netrc_only_trusted": "Papildus uzticamie klonēšanas spraudņiem" }, "pipeline_path": { "default": "Pēc noklusējuma: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Ceļš uz konvejerdarba konfigurāciju, piemēram, {0}. Mapēm jābeidzas ar {0}.", "desc_path_example": "mans/ceļš/", "path": "Konvejerdarba ceļš" }, "project": "Projekta iestatījumi", "protected": { "desc": "Nepieciešams apstiprināt visus konvejerdarbus pirms tie tiek izpildīti.", "protected": "Aizsargāts" }, "save": "Saglabāt iestatījumus", "success": "Projekta iestatījumi tika saglabāti", "timeout": { "minutes": "minūtes", "timeout": "Noildze" }, "trusted": { "desc": "Konvejerdarba konteineri tiks izpildīti ar paaugstinātām tiesībām (piemēram, piesaistīt servera direktorijas).", "trusted": "Uzticams" }, "visibility": { "internal": { "desc": "Tikai autorizēti lietotāji var piekļūt šim projektam.", "internal": "Iekšējs" }, "private": { "desc": "Tikai lietotāji, kam ir tiesības uz repozitoriju, var piekļūt šim projektam.", "private": "Privāts" }, "public": { "desc": "Ikviens var piekļūt projektam, arī neautorizētie lietotāji.", "public": "Publisks" }, "visibility": "Projekta redzamība" }, "allow_deploy": { "desc": "Atļaut publicēšanu no veiksmīgiem konvejerdarbiem. Lietojiet tikai, ja uzticaties visiem lietotājiem ar iesūtīšanas tiesībām.", "allow": "Atļaut publicēšanu" } }, "not_allowed": "Nav piekļuves šī repozitorija iestatījumiem", "registries": { "add": "Pievienot reģistru", "address": { "address": "Adrese", "placeholder": "Reģistra adrese, piemēram, docker.io" }, "created": "Reģistra autorizācijas dati pievienoti", "credentials": "Reģistru autorizācijas dati", "delete": "Dzēst reģistra autorizācijas datus", "deleted": "Reģistra autorizācijas dati dzēsti", "desc": "Reģistru autorizācijas dati var tikt izmantoti, lai izmantotu attēlos no privātiem reģistriem, konvjerdarbu soļos.", "edit": "Labot reģistra autorizācijas datus", "none": "Pašlaik nav pievienots neviens reģistrs.", "registries": "Reģistri", "save": "Saglabāt reģistru", "saved": "Reģistra autorizācijas dati saglabāti", "show": "Reģistru saraksts" }, "secrets": { "add": "Pievienot noslēpumu", "created": "Noslēpums izveidots", "delete": "Dzēst noslēpumu", "delete_confirm": "Vai patiešām vēlaties dzēst šo noslēpumu?", "deleted": "Noslēpums dzēsts", "desc": "Noslēpumus var padot individuāliem konvejerdarba soļiem izpildes laikā kā vides mainīgos.", "edit": "Labot noslēpumu", "events": { "events": "Pieejams šādiem notikumiem", "pr_warning": "Uzmanieties, jo šādā veidā tas būs pieejams visiem cilvēkiem, kas var iesūtīt izmaiņu pieprasījumu." }, "images": { "desc": "Ar komatiem atdalīts saraksts ar attēliem, kam šis noslēpums būs pieejams, atstājot tukšu, tas būs pieejams visiem attēliem", "images": "Pieejams šādiem attēliem" }, "name": "Nosaukums", "none": "Pagaidām nav neviena noslēpuma.", "plugins_only": "Pieejams tikai spraudņiem", "save": "Saglabāt noslēpumu", "saved": "Noslēpums saglabāts", "secrets": "Noslēpumi", "show": "Noslēpumu saraksts", "value": "Vērtība" }, "settings": "Iestatījumi" }, "user_none": "Šai organizācijai/lietotājam pagaidām nav neviena projekta.", "visibility": { "visibility": "Projekta redzamība", "public": { "public": "Publisks", "desc": "Jebkurš var redzēt šo projektu bez autentifikācijas." }, "private": { "private": "Privāts", "desc": "Tikai repozitorija dalībnieki var redzēt šo projektu." }, "internal": { "internal": "Iekšējs", "desc": "Visi autentificētie lietotāji šajā serverī var redzēt šo projektu." } } }, "repos": "Repozitorijas", "repositories": { "all": { "desc": "Repozitoriji sakārtoti pēc pēdējā konvejerdarba izveides laika", "title": "Visi repozitoriji" }, "title": "Repozitoriji", "last": { "title": "Pēdējo reizi aplūkots", "desc": "Visbiežāk skatītie repozitoriji sakārtoti pēc pēdējā aplūkošanas laika" } }, "running_version": "Tiek izmantota Woodpecker {0}", "search": "Meklēt…", "time": { "days_short": "dien.", "hours_short": "st.", "min_short": "min.", "not_started": "nav uzsākts", "sec_short": "sek.", "template": "YYYY. [gada] D. MMMM, HH:mm z", "weeks_short": "ned.", "just_now": "tikko" }, "unknown_error": "Notika neparedzēta kļūda", "update_woodpecker": "Lūdzu atjauniniet Woodpecker instanci uz {0}", "url": "URL", "user": { "access_denied": "Jums nav tiesību autorizēties", "internal_error": "Notikusi sistēmas iekšējā kļūda", "oauth_error": "Neizdevās autorizēties, izmantojot, OAuth piegādātāju", "settings": { "api": { "api": "API", "api_usage": "Piemērs API lietošanai", "cli_usage": "Piemērs komandrindas lietošanai", "desc": "Personīgā piekļuves pilnvara un API lietošana", "dl_cli": "Lejupielādēt komandrindas rīku", "reset_token": "Atiestatīt pilnvaru", "shell_setup": "Komandrindas iestatīšana", "shell_setup_before": "nepieciešamie komandrindas iestatīšanas soļi", "swagger_ui": "API dokumentācija", "token": "Personīgā piekļuves pilnvara" }, "general": { "general": "Vispārīgi", "language": "Valoda", "theme": { "auto": "Noteikt automātiski", "dark": "Tumšā", "light": "Gaišā", "theme": "Tēma" } }, "secrets": { "add": "Pievienot noslēpumu", "created": "Lietotāja noslēpums tika izveidots", "deleted": "Lietotāja noslēpums tika izdzēsts", "desc": "Lietotāja noslēpumi var tikt padoti personīgajiem konvejerdarbu soļiem kā vides mainīgie.", "events": { "events": "Pieejams notikumiem", "pr_warning": "Esiet uzmanīgi atzīmējot šo pazīmi, jo tas var tikt izmantots, lai izmaiņu pieprasījumā atklātu šī noslēpuma vērtību, nepiederošām personām." }, "images": { "desc": "Ar komatu attdalīts saraksts ar attēlu nosaukumiem, kam šis noslēpums būs pieejams, atstājiet tukšu, lai tas būtu pieejams visiem attēliem", "images": "Pieejams šādiem soļu attēliem" }, "name": "Nosaukums", "none": "Pagaidām nav neviena lietotāja noslēpuma.", "plugins_only": "Pieejams tikai spraudņiem", "save": "Saglabāt noslēpumu", "saved": "Lietotāja noslēpums tika saglabāts", "secrets": "Noslēpumi", "show": "Parādīt noslēpumus", "value": "Vērtība" }, "settings": "Lietotāja iestatījumi", "registries": { "desc": "Lietotāju reģistra pilnvaras var tikt pielietotas privātos attēlos priekš personīgiem konvejerdarbiem." }, "cli_and_api": { "token": "Personīgās Piekļuves Žetons", "api_usage": "Piemēra API pielietošana", "cli_usage": "Piemēra CLI lietošana", "download_cli": "Lejupielādēt CLI", "reset_token": "Atiestatīt žetonu", "swagger_ui": "Swagger UI", "cli_and_api": "CLI & API", "desc": "Personīgās Piekļuves Žetons, CLI un API pielietošna" } } }, "username": "Lietotājvārds", "welcome": "Woodpecker", "secrets": { "secrets": "Noslēpumi", "none": "Pašlaik nav vides noslēpumu.", "add": "Pievienot noslēpumu", "save": "Saglabāt noslēpumu", "show": "Rādīt noslēpumus", "name": "Nosaukums", "value": "Vērtība", "delete_confirm": "Vai tiešām vēlaties dzēst šo noslēpumu?", "saved": "Noslēpums saglabāts", "events": { "events": "Pieejams sekojošajos notikumos", "pr_warning": "Lūdzu esiet uzmanīgi ar šo iestatījumu: ļaunprātīgs aktieris var iesniegt ļaunprātīgu pull request, kas atklās noslēpumus." }, "images": { "desc": "Saraksts ar attēliem, kuriem šis noslēpums ir pieejams. Atstāt tukšu, lai atļautu visos attēlos.", "images": "Pieejams sekojošiem attēliem" }, "edit": "Rediģēt noslēpumu", "desc": "Noslēpumi var tikt padoti individuāliem konvejerdarbu soļiem kā vides mainīgie.", "deleted": "Noslēpums dzēsts", "created": "Noslēpums izveidots", "delete": "Dzēst noslēpumu" }, "registries": { "delete_confirm": "Vai tiešām vēlaties dzēst šo reģistru?", "created": "Reģistra pilnvaras izveidotas", "view": "Skatīt reģistru", "edit": "Rediģēt reģistru", "delete": "Dzēst reģistru", "saved": "Reģistra pilnvaras saglabātas", "deleted": "Reģistra pilnvaras dzēstas", "save": "Saglabāt reģistru", "add": "Pievienot reģistru", "registries": "Reģistrijas", "credentials": "Reģistriju pilnvaras", "desc": "Reģistriju pilnvaras var tikt pievienotas pielietošanai privātos attēlos priekš konvejerdarbiem.", "none": "Pašlaik nav rēģistrijas pilnvaru.", "address": { "address": "Adreses", "desc": "Reģistrijas adreses (piem. docker.io)" }, "show": "Rādīt reģistrijas" }, "oauth_error": "Kļūda autentificējoties pret OAuth nodrošinātāju", "internal_error": "Notikušas dažas iekšējās kļūdas", "registration_closed": "Reģistrācijas process ir aizvērts", "access_denied": "Jums nav atļaujas piekļūt šai instancei", "invalid_state": "OAuth stāvoklis nav valīds", "org_level_secret": "organizācijas noslēpums", "cli_login_success": "Autorizēšanās CLI veiksmīga", "login_to_cli": "Autorizēties CLI", "login_to_cli_description": "Turpinot, Jūs autorizēs iekš CLI.", "abort": "Aborts", "cli_login_failed": "Autorizešanās pie CLI neizdevās", "return_to_cli": "Jūs varat aizvērt šo cilni un atgriezties pie CLI.", "settings": "Iestatījumi", "login_with": "Autorizēties ar {forge}", "empty_list": "Nav {entity}!", "global_level_secret": "globāls noslēpums", "cli_login_denied": "Autorizešanās pie CLI liegta" } ================================================ FILE: web/src/assets/locales/nb-NO.json ================================================ { "user": { "settings": { "cli_and_api": { "api_usage": "Eksempel på API-bruk", "cli_usage": "Eksempel på CLI-bruk", "cli_and_api": "CLI & API", "desc": "Personlig Tilgangs-Token, CLI og API bruk", "token": "Personlig Tilgangs-Token", "download_cli": "Last ned CLI", "reset_token": "Nullstill token", "swagger_ui": "Swagger UI" }, "general": { "theme": { "dark": "Mørkt", "theme": "Tema", "light": "Lyst", "auto": "Automatisk" }, "general": "Konto", "language": "Språk" }, "settings": "Bruker-instillinger", "secrets": { "desc": "Bruker-hemmeligheter kan brukes i alle arbeidsflyter eid av brukeren." }, "registries": { "desc": "Brukerens register-hemmeligheter kan brukes for å hente private imager for alle personlige arbeidsflyter." }, "agents": { "desc": "Agenter som er registrert til dine personlige repoer." } } }, "registries": { "desc": "Register-hemmeligheter kan bli lagt til for å bruke private imager i arbeidsflyter.", "created": "Register-hemmeligheter opprettet", "registries": "Registere", "credentials": "Register-hemmeligheter", "none": "Det finnes ingen register-hemmeligheter enda.", "address": { "address": "Adresse", "desc": "Register Adresse (e.g. docker.io)" }, "show": "Vis registere", "save": "Lagre register", "add": "Legg til register", "view": "Vis register", "edit": "Rediger register", "delete": "Slett register", "delete_confirm": "Ønsker du virkelig å slette dette registeret?", "saved": "Register-hemmeligheter lagret", "deleted": "Register-hemmeligheter slettet" }, "secrets": { "desc": "Hemmeligheter kan bli brukt i alle arbeidsflyter for dette arkivet.", "plugins": { "desc": "Liste over utvidelses-imager hvor denne hemmeligheten er tilgengelig. La være tom for å tillate alle utvidelser og normale steg.", "images": "Kun tilgjengelig for følgende utvidelser" }, "secrets": "Hemmeligheter", "none": "Det finnes ingen hemmeligheter enda.", "add": "Legg til hemmelighet", "save": "Lagre hemmelighet", "show": "Vis hemmeligheter", "name": "Navn", "value": "Verdi", "delete_confirm": "Ønsker du virkelig å slette denne hemmeligheten?", "deleted": "Hemmeligheten ble slettet", "created": "Hemmeligheten ble opprettet", "saved": "Hemmeligheten ble lagret", "events": { "events": "Tilgjengelig for følgende hendelser", "warning": "Å tilgjengeliggjøre hemmeligheter for pull-forespørsler kan la skurker stjele hemmelighetene dine med en ondsinnet pull-forespørsel." }, "edit": "Rediger hemmelighet", "delete": "Slett hemmelighet" }, "require_approval": { "none": "Ingen", "none_desc": "Alle hendelser kjører arbeidsflyter, inkludert pull-forespørsler. Denne instillingen kan være skadelig og er kun anbefalt for private tjenere.", "desc": "Sikre deg mot at ondsinnede arbeidsflyter eksponerer hemmeligheter eller kjører skadelige oppgaver ved å godkjenne dem før kjøring.", "require_approval_for": "Godkjenningskrav", "forks": "Pull-forespørsler fra gafflede arkiver", "pull_requests": "Alle pull-forespørsler", "all_events": "Alle hendelser fra tilbyder", "allowed_users": { "allowed_users": "Tillatte brukere", "desc": "Arbeidsflyter fra disse brukerne trenger aldri godkjenning." } }, "org_level_secret": "organisasjons-hemmelighet", "oauth_error": "Feil under autentisering mot OAuth leverandør", "forges_desc": "Konfigurer tilbydere av arkiver Woodpecker skal kjøre for.", "login": "Logg inn", "repos": "Arkiver", "repositories": { "title": "Arkiver", "all": { "title": "Alle arkiver", "desc": "Arkiver sortert etter siste arbeidsflyt" }, "last": { "title": "Sist besøkt", "desc": "Siste besøkte arkiver sortert etter besøkstid" } }, "docs": "Dokumentasjon", "api": "API", "logout": "Logg ut", "search": "Søk…", "username": "Brukernavn", "password": "Passord", "back": "Tilbake", "unknown_error": "En ukjent feil oppsto", "pipeline_feed": "Arbeidsflyts-historikk", "empty_list": "{entity} finnes ikke!", "not_found": { "back_home": "Tilbake hjem", "not_found": "Oi en 404! Enten har noe gått galt, eller du skrev inn feil adresse :-/" }, "errors": { "not_found": "Tjeneren kunne ikke finne det forespurte objektet" }, "time": { "not_started": "ikke påbegynt", "just_now": "akkurat nå" }, "repo": { "manual_pipeline": { "title": "Start en arbeidsflyt manuelt", "trigger": "Kjør arbeidsflyt", "select_branch": "Velg gren", "variables": { "delete": "Slett variabel", "title": "Ytterligere arbeidsflyts-variabler", "desc": "Oppgi ytterligere variabler for din arbeidsflyt. Variabler med samme navn blir overstyrt.", "name": "Variabel-navn", "value": "Verdi for variabel" }, "show_pipelines": "Vis arbeidsflyter", "no_manual_workflows": "Det var ingen manuelt utførbare jobflyter, eller filteret eksluderte alle" }, "deploy_pipeline": { "title": "Start en utrulling for nåværende arbeidsflyt #{pipelineid}", "enter_target": "Ønsket miljø for utrulling", "enter_task": "Utrullings-oppgave", "trigger": "Rull ut", "variables": { "delete": "Slett variabel", "title": "Ytterligere variabler for arbeidsflyt", "name": "Variabel-navn", "value": "Verdi for variabel", "desc": "Oppgi ytterligere variabler for bruk i din arbeidsflyt. Variabler med samme navn blir overskrevet." } }, "activity": "Aktivitet", "branches": "Grener", "pull_requests": "Pull-forespørsler", "add": "Legg til arkiv", "user_none": "Denne organisasjonen/brukeren har ingen prosjekter enda", "not_allowed": "Du har ikke tilgang til dette arkivet", "enable": { "success": "Arkiv aktivert", "enable": "Aktiver", "enabled": "Allerede aktivert", "disabled": "Deaktivert" }, "visibility": { "visibility": "Synlighet for prosjekt", "public": { "public": "Offentlig", "desc": "Alle kan se prosjektet ditt uten å være logget inn." }, "private": { "private": "Privat", "desc": "Bare du og andre eiere av arkivet kan se dette prosjektet." }, "internal": { "internal": "Intern", "desc": "Bare brukere som er logget inn til denne Woodpecker-installasjonen kan se prosjektet." } }, "settings": { "not_allowed": "Du har ikke tilgang til å se instillingene for dette arkivet", "general": { "general": "Prosjekt", "project": "Prosjekt-instillinger", "save": "Lagre instillingene", "success": "Prosjekt-instillingene er oppdatert", "pipeline_path": { "path": "Arbeidsflyts-sti", "default": "Som standard: .woodpecker/.{'{yaml,yml'} -> .woodpecker.yaml -> .woodpecker.yml", "desc_path_example": "min/sti/", "desc": "Stil til konfigurasjon for arbeidsflyt (for eksempel {0}. Mapper skal slutte med en {1}." }, "allow_pr": { "allow": "Tillat Pull-Forespørsler", "desc": "Tillat kjøring av arbeidsflyter for pull-forespørsler." }, "allow_deploy": { "allow": "Tillat Utrulling", "desc": "Tillat utrulling for vellykkede arbeidsflyter. All brukere med skrive-tilgang kan starte disse, så bruk med varsomhet." }, "netrc_only_trusted": { "netrc_only_trusted": "Egenvalgte tillatte utvidelser for kloning", "desc": "Utvidelser som får tilgang til netrc-akkreditering som kan brukes for å klone arkiver fra tilbyderen eller oppdatere dem hos tilbyderen." }, "trusted": { "trusted": "Betrodd", "network": { "network": "Nettverk", "desc": "Arbeidsflyt-kontainere får tilgang til nettverks-rettigheter som å endre DNS." }, "volumes": { "volumes": "Volumer", "desc": "Arbeidsflyts-kontainere får tilgang til å montere volumer." }, "security": { "security": "Sikkerhet", "desc": "Arbeidsflyts-kontainere får tilgang til sikkerhets-privilegier." } }, "timeout": { "timeout": "Tidsavbrudd", "minutes": "Minutter" }, "cancel_prev": { "cancel": "Avbryt tidligere arbeidsflyter", "desc": "Valgte hendelser avbryter ventende og kjørende arbeidsflyter for den samme hendelsen før de starter nye." } }, "crons": { "crons": "Periodiske oppgaver", "desc": "Periodiske oppgaver (cron) kan brukes for å kjøre gjentakende arbeidsflyter.", "show": "Vis periodiske oppgaver", "add": "Legg til periodisk oppgave", "none": "Det er ingen periodiske oppgaver enda.", "save": "Lagre periodisk oppgave", "created": "Periodisk oppgave opprettet", "saved": "Perioidsk oppgave lagret", "deleted": "Periodisk oppgave slettet", "not_executed_yet": "Ikke kjørt enda", "run": "Kjør nå", "branch": { "title": "Gren", "placeholder": "Gren (Bruker standard gren om ikke valgt)" }, "name": { "name": "Navn", "placeholder": "Navn på periodisk oppgave" }, "schedule": { "title": "Kjøringsplan (basert på UTC)", "placeholder": "Kjøringsplan" }, "edit": "Rediger periodisk oppgave", "delete": "Slett periodisk oppgave", "next_exec": "Neste kjøring", "enabled": "Aktivert" }, "badge": { "badge": "Merke", "type": "Syntaks", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "branch": "Gren", "workflow": "Jobbflyt", "step": "Steg" }, "actions": { "actions": "Handlinger", "repair": { "repair": "Reparer arkiv", "success": "Arkivet er reparert" }, "disable": { "disable": "Deaktiver arkiv", "success": "Arkivet er deaktivert" }, "enable": { "enable": "Aktiver arkiv", "success": "Arkivet er aktivert" }, "delete": { "delete": "Slett arkiv", "confirm": "Alle data vil bli tap etter denne handlingen!\n\nVil du fortsette?", "success": "Arkivet er slettet" } } }, "pipeline": { "tasks": "Oppgaver", "config": "Konfigurasjon", "files": "Endrede filer", "no_pipeline_steps": "Ingen arbeidsflyts-steg er tilgjengelige!", "step_not_started": "Dette steget har ikke startet enda.", "pipelines_for": "Arbeidsflyter for grenen \"{branch}\"", "exit_code": "Avslutnings-kode {exitCode}", "loading": "Laster.…", "no_logs": "Ingen logger", "pipeline": "Arbeidsflyt #{pipelineId}", "log_title": "Logger for Steg", "log_delete_confirm": "Ønsker du virkelig å slette steg-loggene?", "log_delete_error": "En feil oppsto under sletting av steg-loggene", "actions": { "cancel": "Avbryt", "restart": "Start igjen", "canceled": "Dette steget har blitt avbrutt.", "cancel_success": "Arbeidsflyt avbrutt", "restart_success": "Arbeidsflyten er startet på nytt", "log_download": "Last ned", "log_delete": "Slett", "log_auto_scroll_off": "Deaktiver automatisk scrolling", "deploy": "Rull ut", "log_auto_scroll": "Aktiver automatisk scrolling" }, "protected": { "awaits": "Arbeidsflyten venter på godkjenning fra en forvalter!", "approve": "Godkjenn", "decline": "Avvis", "declined": "Denne arbeidsflyten er avvist!", "approve_success": "Arbeidsflyten er godkjent", "decline_success": "Arbeidsflyten er avvist" }, "event": { "push": "Push", "tag": "Tagg", "pr": "Pull-forespørsel", "pr_metadata": "Metadata for Pull-forespørsel endret", "deploy": "Rull ut", "cron": "Gjentagende Oppgave", "release": "Utgivelse", "pr_closed": "Pull-forespørslen er merget/lukket", "manual": "Manuell" }, "status": { "blocked": "blokkert", "pending": "ventende", "running": "kjørende", "started": "startet", "skipped": "hoppet over", "success": "suksess", "declined": "avvist", "error": "feil", "failure": "feilet", "killed": "drept", "status": "Status: {status}" }, "errors": "Feil", "warnings": "Advarsler", "show_errors": "Vis feil", "created": "Opprettet: {created}", "debug": { "title": "Feilsøk", "download_metadata": "Last ned metadata", "metadata_download_error": "En feil oppsto under nedlastning av metadata", "metadata_download_successful": "Metadata ble lastet ned", "no_permission": "Du har ikke tilgang til feilsøkings-informasjon", "metadata_exec_title": "Kjør arbeidsflyten igjen lokalt", "metadata_exec_desc": "Last ned metadata for denne arbeidsflyten for å kjøre den lokalt. Dette lar deg fikse feilene og teste endringer før du lagrer dem til arkivet. Du må bruke samme versjon av Woodpecker-cliet som server-versjonen." }, "view": "Vis arbeidsflyt", "log_download_error": "En feil oppsto under nedlastning av logg-filen", "no_pipelines": "Ingen arbeidsflyter har startet enda.", "we_got_some_errors": "Å nei, en feil oppsto!", "duration": "Varighet for arbeidsflyt: {duration}", "pipelines_for_pr": "Arbeidsflyter for pull-forespørsel #{index}" }, "open_in_forge": "Åpne arkiv hos leverandør" }, "org": { "settings": { "registries": { "desc": "Organisasjonens register-hemmeligheter kan brukes for private imager i alle arbeidsflyter for organisasjonen." }, "agents": { "desc": "Registrerte Agenter for denne organisasjonen." }, "secrets": { "desc": "Organisasjons-hemmeligheter kan brukes i arbeidsflytene til alle arkivene denne organisasjonen eier." }, "not_allowed": "Du har ikke tilgang til instillingene for denne organisasjonen" } }, "admin": { "settings": { "settings": "Admin-Instillinger", "secrets": { "desc": "Globale hemmeligheter kan brukes arbeidsflyter for alle arkiver.", "warning": "Disse hemmelighetene er tilgjengelige for alle brukere." }, "registries": { "desc": "Globale register-hemmeligheter kan bli lagt til for å bruke private imager for alle arbeidsflyter.", "warning": "Disse register-hemmelighetene er tilgjengelige for alle brukere." }, "agents": { "agents": "Agenter", "desc": "Agenter registert på denne tjeneren.", "none": "Ingen agenter er registert enda.", "id": "ID", "add": "Legg til agent", "save": "Lagre agent", "show": "Vis agenter", "created": "Agent er opprettet", "deleted": "Agenten er slettet", "name": { "name": "Navn", "placeholder": "Navn på agenten" }, "no_schedule": { "name": "Deaktivert agent", "placeholder": "Stopp agenten fra å ta nye oppgaver" }, "token": "Token", "platform": { "platform": "Platform", "badge": "platform" }, "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "capacity": "Kapasitet", "desc": "Maksimum antall samtidige arbeidsflyter denne agenten kan kjøre.", "badge": "kapasitet" }, "custom_labels": { "desc": "Egendefinerte merkelapper satt av agent-administrator ved oppstart.", "custom_labels": "Egendefinerte Merkelapper" }, "org": { "badge": "org" }, "version": "Versjon", "last_contact": { "last_contact": "Siste kontakt", "badge": "siste kontakt" }, "never": "Aldri", "edit_agent": "Rediger agent", "delete_agent": "Slett agent", "saved": "Agenten er lagret", "delete_confirm": "Ønsker du virkelig å slette denne agenten? Den vil ikke lenger kunne kontakte tjeneren." }, "queue": { "queue": "Kø", "pause": "Pause", "resume": "Gjenoppta", "paused": "Køen er pauset", "resumed": "Køen er gjenopptatt", "tasks": "Oppgaver", "task_running": "Oppgaven kjører", "task_pending": "Oppgaven venter", "task_waiting_on_deps": "Oppgaven venter på avhengigheter", "agent": "agent", "waiting_for": "venter på", "stats": { "completed_count": "Fullførte Oppgaver", "worker_count": "Ledig", "running_count": "Kjører", "pending_count": "Venter", "waiting_on_deps_count": "Venter på avhengigheter" }, "desc": "Oppgaver som venter på å bli kjørt av agenter." }, "users": { "users": "Brukere", "desc": "Registrerte brukere på denne tjeneren.", "login": "Brukernavn", "email": "Epost", "avatar_url": "URL til Avatar", "save": "Lagre bruker", "cancel": "Avbryt", "show": "Vis brukere", "add": "Legg til bruker", "none": "Det finnes ingen brukere enda.", "deleted": "Bruker slettet", "created": "Bruker opprettet", "saved": "Bruker lagret", "admin": { "admin": "Admin", "placeholder": "Brukeren er en admin" }, "delete_user": "Slett bruker", "edit_user": "Rediger bruker", "delete_confirm": "Ønsker du virkelig å slette denne brukeren? Dette vil også slette arkivene brukeren eier." }, "orgs": { "orgs": "Organisasjoner", "desc": "Organisasjoner som eier arkiver på denne tjeneren.", "none": "Det finnes ingen organisasjoner enda.", "org_settings": "Organisasjons-instillinger", "delete_org": "Slett organisasjon", "deleted": "Organisasjonen er slettet", "delete_confirm": "Ønsker du virkelig å slette denne organisasjonen. Dette vil også slette alle arkiver eid av denne organisasjonen.", "view": "Vis organisasjon" }, "repos": { "repos": "Arkiver", "desc": "Arkiver som er eller ble aktivert på denne tjeneren.", "none": "Det finnes ingen arkiver enda.", "view": "Vis arkiv", "settings": "Arkiv-instillinger", "disabled": "Deaktivert", "repair": { "repair": "Reparer alle", "success": "Arkiver er reparert" } }, "not_allowed": "Du har ikke tilgang til å se server-instillingene" } }, "default": "standard", "info": "Info", "running_version": "Du kjører Woodpecker {0}", "update_woodpecker": "Vennligst oppdater din Woodpecker-tjener til {0}", "global_level_secret": "global hemmelighet", "login_to_cli": "Logg inn til CLI", "login_to_cli_description": "Hvis du fortsetter blir du logget inn til CLIet.", "abort": "Avbryt", "cli_login_success": "Innlogging til CLIet var vellykket", "cli_login_failed": "Innlogging til CLIet feilet", "cli_login_denied": "Innlogging til CLIet ble avvist", "return_to_cli": "Du kan nå lukke denne fanen og returnere til CLIet.", "settings": "Instillinger", "internal_error": "En intern feil oppsto", "registration_closed": "Registrering er stengt", "access_denied": "Du har ikke tilgang til denne tjeneren", "org_access_denied": "Du har ikke tilgang til denne organisasjonen", "invalid_state": "OAuth statusen er ugyldig", "no_search_results": "Ingen resultater ble funnet", "forges": "Tilbydere", "add_forge": "Legg til tilbyder", "show_forges": "Vis tilbydere", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Tillegg", "forge_type": "Tilbyder-type", "oauth_client_id": "OAuth Klient-ID", "oauth_client_secret": "OAuth Klient-Hemmelighet", "oauth_host": "OAuth adresse", "merge_ref": "Merge ref", "merge_ref_desc": "Referanse for merge basis. Dette blir brukt for å finne endringer for Pull-forespørsler.", "public_only": "Kun offentlig", "public_only_desc": "Bare vis offentlige arkiver.", "git_username": "Git brukernavn", "git_username_desc": "Brukernavn for Git brukeren.", "git_password": "Git passord", "git_password_desc": "Passord eller personlig tilgangs-token for Git brukeren.", "executable": "Kjørbar fil", "executable_desc": "Sti til kjørbar fil for tillegget.", "save": "Lagre", "add": "Legg til", "skip_verify": "Hopp over verifisering av SSL", "skip_verify_desc": "Hopp over verifisering av SSL for API-forbindelsen. Dette er ikke anbefalt i produksjon.", "url": "URL", "forge_managed_by_env": "Hoved-tilbyderen er administrert gjennom miljøvariabler. Alle endringer til denne tilbyderen vil bli tilbakesatt ved omstart.", "oauth_redirect_url": "URL for OAuth videresending", "forge_created": "Tilbyderen er opprettet", "advanced_options": "Avanserte valg", "leave_empty_to_keep_current_value": "La stå tom for å beholde nåværende verdi", "forge_deleted": "Tilbyderen er slettet", "forge_delete_confirm": "Ønsker du virkelig å slette denn tilbyderen? Dette vil også slette alle arkiver, brukere og arbeidsflyter tilknyttet denne tilbyderen.", "edit_forge": "Rediger tilbyder", "delete_forge": "Slett tilbyder", "no_forges": "Det finnes ingen tilbydere enda.", "use_this_redirect_url_to_create": "Bruk denne videresendings-URLen for å opprette eller oppdatere OAuth-applikasjonen.", "developer_settings_to_create": "Gå til {0} og sett opp OAuth applikasjonen.", "developer_settings": "utvikler-instillinger", "public_url_for_oauth_if": "Offentlig URL for OAuth om forskjellig fra URLen ({0})", "forge_saved": "Tilbyderen er lagret", "fullscreen": "Fullskjerm", "exit_fullscreen": "Avslutt fullskjerm", "help_translating": "Du kan bidra til å oversette Woodpecker til ditt språk på {0}.", "weblate": "vår Weblate", "cancel": "Avbryt", "documentation_for": "Dokumentasjon for \"{topic}\"", "login_to_woodpecker_with": "Log inn til Woodpecker med", "extensions": "Utvidelser", "extensions_description": "Utvidelser er HTTP-tjenester som kan bli kalt av Woodpecker istedenfor å bruke de innebygde tjenesten.", "extension_endpoint_placeholder": "f.eks. https://eksempel.no/api", "config_extension_endpoint": "Konfigurer endepunkt for utvidelse", "extensions_signatures_public_key": "Offentlig nøkkel for signaturer", "extensions_signatures_public_key_description": "Denne offentlige nøkkelen skal brukes av dine utvidelser for å verifisere webhook-kall fra Woodpecker." } ================================================ FILE: web/src/assets/locales/nl.json ================================================ { "api": "API", "back": "Terug", "cancel": "Annuleren", "docs": "Documentatie", "documentation_for": "Documentatie voor \"{topic}\"", "errors": { "not_found": "Object kon niet op de server gevonden worden" }, "login": "Inloggen", "logout": "Uitloggen", "not_found": { "back_home": "Terug naar startpagina", "not_found": "Whoa 404, of wij hebben iets gesloopt of je hebt een typfoutje gemaakt :-/" }, "password": "Wachtwoord", "pipeline_feed": "Pipeline feed", "repo": { "activity": "Activiteit", "add": "Repository toevoegen", "branches": "Branches", "deploy_pipeline": { "enter_target": "Doelomgeving deployment", "title": "Voer deployment event uit voor de huidige pipeline #{pipelineId}", "trigger": "Rol uit", "variables": { "add": "Voeg variabele toe", "desc": "Specificeer additionele variabelen voor gebruik in je pipeline. Variabelen met een bestaande naam worden overschreven.", "name": "Variabele naam", "title": "Additionele pipeline variabelen", "value": "Variabele waarde" } }, "enable": { "enable": "Activeer", "enabled": "Al ingeschakeld", "list_reloaded": "Repository lijst herladen", "reload": "Opslagplaatsen herladen", "success": "Repository ingeschakeld", "disabled": "Uitgeschakeld" }, "manual_pipeline": { "select_branch": "Selecteer branch", "title": "Activeer een handmatige pipeline run", "trigger": "Pipeline uitvoeren", "variables": { "add": "Voeg variabele toe", "desc": "Specificeer aanvullende variabelen om in je pipeline te gebruiken. Variabelen met dezelfde naam worden overschreven.", "name": "Variabele naam", "title": "Aanvullende variabelen", "value": "Variabele waarde" } }, "not_allowed": "Je hebt geen toegang tot dit archief", "pull_requests": "Pull verzoeken", "settings": { "general": { "general": "Algemeen", "project": "Project instellingen", "save": "Instellingen opslaan", "success": "Repository instellingen geüpdatet", "timeout": { "minutes": "minuten" }, "trusted": { "trusted": "Vertrouwd" }, "visibility": { "internal": { "desc": "Alleen geauthenticeerde gebruikers van deze Woodpecker instantie kunnen dit project zien." }, "private": { "private": "Privé" }, "visibility": "Project zichtbaarheid" } }, "secrets": { "add": "Geheim toevoegen", "created": "Geheim aangemaakt", "delete": "Geheim verwijderen", "deleted": "Geheim verwijderd", "edit": "Geheim wijzigen", "images": { "images": "Beschikbaar voor de volgende afbeeldingen" }, "name": "Naam", "save": "Geheim opslaan", "saved": "Geheim opgeslagen", "secrets": "Geheimen", "show": "Geheimen weergeven", "value": "Waarde" }, "settings": "Instellingen" }, "user_none": "Deze organisatie / gebruiker heeft nog geen projecten." }, "repos": "Repos", "repositories": { "last": { "title": "Laatst Bezocht" } }, "search": "Zoeken…", "time": { "days_short": "d", "hours_short": "u", "min_short": "min", "not_started": "nog niet gestart", "sec_short": "sec", "template": "DD.MM.YYYY, HH:mm z", "weeks_short": "w" }, "unknown_error": "Er is een onbekende fout opgetreden", "url": "URL", "user": { "access_denied": "U heeft niet de rechten om in te loggen", "internal_error": "Er is een interne fout opgetreden" }, "username": "Gebruikersnaam", "welcome": "Welkom bij Woodpecker", "login_to_woodpecker_with": "Inloggen bij Woodpecker met" } ================================================ FILE: web/src/assets/locales/pl.json ================================================ { "admin": { "settings": { "agents": { "add": "Dodaj agenta", "agents": "Agenty", "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "badge": "pojemność", "capacity": "Pojemność", "desc": "Maksymalna liczba równoległych potoków wykonywanych przez tego agenta." }, "created": "Utworzono agenta", "delete_agent": "Usuń agenta", "delete_confirm": "Czy na pewno chcesz usunąć tego agenta? Nie będzie można go więcej połączyć z serwerem.", "deleted": "Usunięto agenta", "desc": "Agenty zarejestrowane na tym serwerze", "edit_agent": "Edytuj agenta", "last_contact": "Ostatni kontakt", "name": { "name": "Nazwa", "placeholder": "Nazwa agenta" }, "never": "Nigdy", "no_schedule": { "name": "Dezaktywuj agenta", "placeholder": "Nie pozwalaj agentowi pobierać nowych zadań" }, "none": "Nie ma jeszcze żadnego agenta.", "platform": { "badge": "platforma", "platform": "Platforma" }, "save": "Zapisz agenta", "saved": "Zapisano agenta", "show": "Pokaż agenty", "token": "Token", "version": "Wersja", "id": "ID" }, "not_allowed": "Nie masz pozwolenia na dostęp do ustawień serwera", "queue": { "agent": "agent", "desc": "Zadania oczekujące na wykonanie przez agentów", "pause": "Wstrzymaj", "paused": "Kolejka jest wstrzymana", "queue": "Kolejka", "resume": "Wznów", "resumed": "Kolejka jest wznowiona", "stats": { "completed_count": "Zadania zakończone", "pending_count": "Oczekujące", "running_count": "Uruchomione", "waiting_on_deps_count": "Oczekiwanie na zależności", "worker_count": "Wolne" }, "task_pending": "Zadanie jest oczekujące", "task_running": "Zadanie jest w toku", "task_waiting_on_deps": "Zadanie oczekuje na zależności", "tasks": "Zadania", "waiting_for": "oczekuje na" }, "secrets": { "add": "Dodaj sekret", "created": "Dodano sekret globalny", "deleted": "Usunięto sekret globalny", "desc": "Globalne sekrety mogą być przekazane jako zmienne środowiskowe do poszczególnych kroków potoku wszystkich repozytoriów.", "events": { "events": "Dostępny dla wybranych zdarzeń", "pr_warning": "Używaj tej opcji ostrożnie, osoba atakująca może złożyć szkodliwy pull request który ujawni twoje sekrety." }, "images": { "desc": "Lista obrazów (rozdzielonych przecinkami) dla których ten sekret jest dostępny, pozostaw puste aby zezwolić dla wszystkich obrazów", "images": "Dostępny dla wybranych obrazów" }, "name": "Nazwa", "none": "Nie ma jeszcze żadnych globalnych sekretów.", "plugins_only": "Dostępny tylko dla pluginów", "save": "Zapisz sekret", "saved": "Zapisano sekret globalny", "secrets": "Sekrety", "show": "Pokaż sekrety", "value": "Wartość", "warning": "Te sekrety będą dostępne dla wszystkich użytkowników serwera." }, "settings": "Ustawienia", "users": { "add": "Dodaj użytkownika", "admin": { "admin": "Administrator", "placeholder": "Użytkownik jest administratorem" }, "avatar_url": "Adres URL awatara", "cancel": "Anuluj", "created": "Dodano użytkownika", "delete_confirm": "Czy naprawdę chcesz usunąć tego użytkownika?", "delete_user": "Usuń użytkownika", "deleted": "Usunięto użytkownika", "desc": "Użytkownicy zarejestrowani na tym serwerze", "edit_user": "Edytuj użytkownika", "email": "Email", "login": "Logowanie", "none": "Nie ma jeszcze użytkowników.", "save": "Zapisz użytkownika", "saved": "Zapisano użytkownika", "show": "Pokaż użytkowników", "users": "Użytkownicy" }, "orgs": { "orgs": "Organizacje" }, "repos": { "repos": "Repozytoria" } } }, "back": "Powrót", "cancel": "Anuluj", "docs": "Dokumentacja", "documentation_for": "Dokumentacja do tematu \"{topic}\"", "errors": { "not_found": "Serwer nie mógł znaleźć żądanego obiektu" }, "login": "Zaloguj", "logout": "Wyloguj", "not_found": { "back_home": "Powrót do strony głównej", "not_found": "Ojoj, 404, albo coś zepsuliśmy, albo pomyliłeś się przy wpisywaniu :-/" }, "org": { "settings": { "not_allowed": "Nie masz pozwolenia na dostęp do ustawień tej organizacji", "secrets": { "add": "Dodaj sekret", "created": "Dodano sekret organizacji", "deleted": "Usunięto sekret organizacji", "desc": "Sekrety organizacji mogą być przekazane jako zmienne środowiskowe do poszczególnych kroków potoku wszystkich repozytoriów tej organizacji.", "events": { "events": "Dostępny dla wybranych zdarzeń", "pr_warning": "Używaj tej opcji ostrożnie, osoba atakująca może złożyć szkodliwy pull request który ujawni twoje sekrety." }, "images": { "desc": "Lista obrazów (rozdzielonych przecinkami) dla których ten sekret jest dostępny, pozostaw puste aby zezwolić dla wszystkich obrazów", "images": "Dostępny dla wybranych obrazów" }, "name": "Nazwa", "none": "Nie ma jeszcze żadnych sekretów organizacji.", "plugins_only": "Dostępny tylko dla pluginów", "save": "Zapisz sekret", "saved": "Zapisano sekret organizacji", "secrets": "Sekrety", "show": "Pokaż sekrety", "value": "Wartość" }, "settings": "Ustawienia", "agents": { "desc": "Agenty zarejestrowane dla tej organizacji." }, "registries": { "desc": "Można dodać dane rejestrów organizacji, aby używać prywatnych obrazów dla wszystkich potoków organizacji." } } }, "password": "Hasło", "pipeline_feed": "Tablica potoków", "repo": { "activity": "Aktywności", "add": "Dodaj repozytorium", "branches": "Gałęzie", "deploy_pipeline": { "enter_target": "Docelowe środowisko wdrażania", "title": "Wyzwalanie zdarzenia deploymentu dla bieżącego potoku #{pipelineId}", "trigger": "Wdrażanie", "variables": { "add": "Dodaj zmienną", "desc": "Zdefiniuj dodatkowe zmienne użyte w twoim potoku. Zmienne o tej samej nazwie zostaną nadpisane.", "name": "Nazwa zmiennej", "title": "Dodatkowe zmienne potoku", "value": "Wartość zmiennej", "delete": "Usuń zmienną" }, "enter_task": "Zadanie wdrażania" }, "enable": { "enable": "Aktywuj", "enabled": "Już aktywowano", "list_reloaded": "Wczytano listę repozytoriów ponownie", "reload": "Wczytaj repozytoria ponownie", "success": "Zaktywowano", "disabled": "Dezaktywowany" }, "manual_pipeline": { "select_branch": "Wybierz gałąź", "title": "Wyzwól ręcznie uruchomienie potoku", "trigger": "Uruchom potok", "variables": { "add": "Dodaj zmienną", "desc": "Zdefiniuj dodatkowe zmienne użyte w twoim potoku. Zmienne o tej samej nazwie zostaną nadpisane.", "name": "Nazwa zmiennej", "title": "Dodatkowe zmienne potoku", "value": "Wartość zmiennej", "delete": "Delete variable" }, "show_pipelines": "Pokaż potoki" }, "not_allowed": "Nie masz pozwolenia na dostęp do tego repozytorium", "open_in_forge": "Otwórz repozytorium w systemie kontroli wersji", "pipeline": { "actions": { "cancel": "Anuluj", "cancel_success": "Anulowano potok", "canceled": "Ten krok został anulowany.", "deploy": "Wdrażanie", "log_auto_scroll": "Przewijaj automatycznie", "log_auto_scroll_off": "Wyłącz automatyczne przewijanie", "log_download": "Pobierz", "restart": "Uruchom ponownie", "restart_success": "Uruchomiono potok ponownie", "log_delete": "Usuń" }, "config": "Konfiguracja", "event": { "cron": "Cron", "deploy": "Wdrażanie", "manual": "Ręcznie", "pr": "Pull Request", "push": "Push", "tag": "Tag", "release": "Wydanie", "pr_closed": "Pull Request scalony/zamknięty", "pr_metadata": "Zmieniono metadane Pull Requesta" }, "exit_code": "Kod wyjścia {exitCode}", "files": "Zmodyfikowane pliki ({files})", "loading": "Ładowanie…", "log_download_error": "Wystąpił błąd podczas pobierania pliku z logiem", "no_files": "Nie zmodyfikowano żadnych plików.", "no_pipeline_steps": "Brak dostępnych kroków potoku!", "no_pipelines": "Nie uruchomiono jeszcze żadnego potoku.", "pipeline": "Potok #{pipelineId}", "pipelines_for": "Potoki dla gałęzi \"{branch}\"", "pipelines_for_pr": "Potoki dla pull requesta #{index}", "protected": { "approve": "Zatwierdź", "approve_success": "Zatwierdzono potok", "awaits": "Ten potok oczekuje na zatwierdzenie przez maintainera!", "decline": "Odrzuć", "decline_success": "Odrzucono potok", "declined": "Ten potok został odrzucony!" }, "status": { "blocked": "zablokowany", "declined": "odrzucony", "error": "błąd", "failure": "zakończony niepowodzeniem", "killed": "zabity", "pending": "oczekujący na wykonanie", "running": "w trakcie uruchomienia", "skipped": "pominięty", "started": "rozpoczęty", "status": "Status: {status}", "success": "zakończony powodzeniem" }, "step_not_started": "Ten krok jeszcze się nie rozpoczął.", "tasks": "Zadania", "log_title": "Logi", "we_got_some_errors": "Ojej, coś poszło nie tak!", "no_logs": "Brak logów", "log_delete_confirm": "Czy na pewno chcesz usunąć logi kroku?", "errors": "Błędy", "duration": "Czas trwania potoku: {duration}", "show_errors": "Pokaż błędy", "created": "Utworzono: {created}", "debug": { "metadata_exec_desc": "Pobierz metadane tego potoku, aby uruchomić go lokalnie. Pozwala to na naprawę problemów i testowanie zmian przed ich zatwierdzeniem. Woodpecker CLI musi być zainstalowane lokalnie w tej samej wersji co serwer.", "download_metadata": "Pobierz metadane", "metadata_download_error": "Błąd podczas pobierania metadanych", "no_permission": "Nie masz pozwolenia na dostęp do informacji debugowania", "metadata_exec_title": "Uruchom potok ponownie lokalnie", "title": "Debugowanie", "metadata_download_successful": "Pomyślnie pobrano metadane" }, "view": "Zobacz potok", "log_delete_error": "Błąd podczas usuwania logów kroku", "warnings": "Ostrzeżenia" }, "pull_requests": "Pull requesty", "settings": { "actions": { "actions": "Operacje", "delete": { "confirm": "Wszystkie dane zostaną utracone po wykonaniu tej operacji!!!\n\nCzy na pewno chcesz kontynuować?", "delete": "Usuń repozytorium", "success": "Usunięto repozytorium" }, "disable": { "disable": "Dezaktywuj repozytorium", "success": "Dezaktywowano repozytorium" }, "enable": { "enable": "Włącz repozytorium", "success": "Włączono repozytorium" }, "repair": { "repair": "Napraw repozytorium", "success": "Naprawiono repozytorium" } }, "badge": { "badge": "Plakietka", "branch": "Gałąź", "type": "Składnia", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL" }, "crons": { "add": "Dodaj cron", "branch": { "placeholder": "Gałąź (używa domyślnej gałęzi jeśli puste)", "title": "Gałąź" }, "created": "Dodano cron", "crons": "Crony", "delete": "Usuń cron", "deleted": "Usunięto cron", "desc": "Zadania cron mogą być używane do wyzwalania potoków w regularnym odstępie.", "edit": "Edytuj cron", "name": { "name": "Nazwa", "placeholder": "Nazwa zadania cron" }, "next_exec": "Następne uruchomienie", "none": "Nie dodano jeszcze żadnego crona.", "not_executed_yet": "Jeszcze nigdy nie uruchomiono", "run": "Uruchom teraz", "save": "Zapisz cron", "saved": "Zapisano cron", "schedule": { "placeholder": "Harmonogram", "title": "Harmonogram (wyrażony w UTC)" }, "show": "Pokaż crony" }, "general": { "allow_pr": { "allow": "Zezwól na pull requesty", "desc": "Potoki mogą być uruchamiane przy pull requestach." }, "cancel_prev": { "cancel": "Anuluj wcześniejsze potoki", "desc": "Zaznacz aby anulować oczekujące i uruchomione potoki dla tego samego zdarzenia i kontekstu przed rozpoczęciem nowego potoku." }, "general": "Ogólne", "netrc_only_trusted": { "desc": "Wstrzykuj poświadczenia netrc tylko do zaufanych kontenerów (zalecane).", "netrc_only_trusted": "Wstrzykuj poświadczenia netrc tylko do zaufanych kontenerów" }, "pipeline_path": { "default": "Domyślnie: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Ścieżka do konfiguracji Twojego potoku (na przykład {0}). Foldery powinny kończyć się znakiem {1}.", "desc_path_example": "moja/ścieżka/", "path": "Ścieżka do pliku potoku" }, "project": "Ustawienia projektu", "protected": { "desc": "Każdy potok musi zostać zatwierdzony przed uruchomieniem.", "protected": "Chroniony" }, "save": "Zapisz ustawienia", "success": "Zaktualizowano ustawienia projektu", "timeout": { "minutes": "minut", "timeout": "Limit czasu wykonania" }, "trusted": { "desc": "Kontenery potoku otrzymają dostęp do podwyższonych operacji, takich jak montowanie woluminów.", "trusted": "Zaufany", "network": { "network": "Sieć", "desc": "Kontenery potoku otrzymują dostęp do uprawnień sieciowych, takich jak zmiana DNS." }, "volumes": { "desc": "Kontenery potoku mogą montować woluminy.", "volumes": "Woluminy" }, "security": { "security": "Bezpieczeństwo", "desc": "Kontenery potoku otrzymują dostęp do uprawnień bezpieczeństwa." } }, "visibility": { "internal": { "desc": "Tylko zalogowani użytkownicy instancji Woodpecker mogą zobaczyć ten projekt.", "internal": "Wewnętrzny" }, "private": { "desc": "Tylko ty i inni właściciele repozytorium mogą zobaczyć ten projekt.", "private": "Prywatny" }, "public": { "desc": "Każdy użytkownik może zobaczyć twój projekt bez bycia zalogowanym.", "public": "Publiczny" }, "visibility": "Widoczność projektu" }, "allow_deploy": { "allow": "Zezwól na wdrożenia", "desc": "Zezwól na wdrożenia dla udanych potoków. Wszyscy użytkownicy z uprawnieniami do push mogą je wyzwalać, więc używaj ostrożnie." } }, "not_allowed": "Nie masz pozwolenia na dostęp do ustawień tego repozytorium", "registries": { "add": "Dodaj rejestr", "address": { "address": "Adres", "placeholder": "Adres rejestru (np. docker.io)" }, "created": "Utworzono dane rejestru", "credentials": "Dane rejestrów", "delete": "Usuń rejestr", "deleted": "Usunięto dane rejestru", "desc": "Możesz dodać dane rejestrów aby używać prywatnych obrazów w twoim potoku.", "edit": "Edytuj rejestr", "none": "Nie dodano jeszcze żadnego rejestru.", "registries": "Rejestry", "save": "Zapisz rejestr", "saved": "Zapisano dane rejestru", "show": "Pokaż rejestry" }, "secrets": { "add": "Dodaj sekret", "created": "Dodano sekret", "delete": "Usuń sekret", "delete_confirm": "Czy naprawdę chcesz usunąć ten sekret?", "deleted": "Usunięto sekret", "desc": "Sekrety są przekazywane do poszczególnych kroków potoku jako zmienne środowiskowe.", "edit": "Edytuj sekret", "events": { "events": "Dostępny dla wybranych zdarzeń", "pr_warning": "Używaj tej opcji ostrożnie, osoba atakująca może złożyć szkodliwy pull request który ujawni twoje sekrety." }, "images": { "desc": "Lista obrazów (rozdzielonych przecinkami) dla których ten sekret jest dostępny, pozostaw puste aby zezwolić dla wszystkich obrazów", "images": "Dostępny dla wybranych obrazów" }, "name": "Nazwa", "none": "Nie ma jeszcze żadnych sekretów.", "plugins_only": "Dostępny tylko dla pluginów", "save": "Zapisz sekret", "saved": "Zapisano sekret", "secrets": "Sekrety", "show": "Pokaż sekrety", "value": "Wartość" }, "settings": "Ustawienia" }, "user_none": "Ta organizacja / użytkownik nie ma jeszcze żadnych projektów.", "visibility": { "private": { "desc": "Tylko ty i inni właściciele repozytorium mogą zobaczyć ten projekt.", "private": "Prywatny" }, "visibility": "Widoczność projektu", "public": { "public": "Publiczny", "desc": "Każdy może zobaczyć twój projekt bez logowania." }, "internal": { "internal": "Wewnętrzny", "desc": "Tylko zalogowani użytkownicy instancji Woodpecker mogą zobaczyć ten projekt." } } }, "repos": "Repozytoria", "repositories": { "title": "Repozytoria", "all": { "title": "Wszystkie repozytoria", "desc": "Repozytoria posortowane według ostatniego utworzenia potoku" }, "last": { "title": "Ostatnio odwiedzone", "desc": "Ostatnio odwiedzone repozytoria posortowane według czasu dostępu" } }, "search": "Szukaj…", "time": { "days_short": "d", "hours_short": "godz", "min_short": "min", "not_started": "jeszcze nie rozpoczęto", "sec_short": "sek", "template": "DD.MM.YYYY, HH:mm z", "weeks_short": "tyg", "just_now": "przed chwilą" }, "unknown_error": "Wystąpił nieznany błąd", "url": "URL", "user": { "access_denied": "Nie masz pozwolenia na zalogowanie", "internal_error": "Wystąpił błąd wewnętrzny", "oauth_error": "Błąd podczas uwierzytelnienia u dostawcy OAuth", "settings": { "api": { "dl_cli": "Pobierz CLI", "cli_usage": "Przykład użycia CLI", "shell_setup": "Przygotowanie powłoki", "api_usage": "Przykład użycia API", "api": "API", "reset_token": "Zresetuj token" }, "settings": "Ustawienia użytkownika", "secrets": { "deleted": "Usunięto sekret użytkownika", "name": "Nazwa", "value": "Wartość", "secrets": "Sekrety", "none": "Nie ma jeszcze żadnych sekretów użytkownika.", "add": "Dodaj sekret", "save": "Zapisz sekret", "show": "Pokaż sekrety", "created": "Dodano sekret użytkownika", "saved": "Zapisano sekret użytkownika" }, "general": { "theme": { "auto": "Automatyczny", "dark": "Ciemny", "light": "Jasny", "theme": "Motyw" }, "language": "Język", "general": "Ogólne" } } }, "username": "Nazwa użytkownika", "welcome": "Witamy w Woodpecker", "empty_list": "Nie znaleziono {entity}!", "api": "API", "login_to_woodpecker_with": "Zaloguj się do Woodpecker za pomocą" } ================================================ FILE: web/src/assets/locales/pt.json ================================================ { "back": "Voltar", "cancel": "Cancelar", "docs": "Documentação", "documentation_for": "Documentação para \"{topic}\"", "errors": { "not_found": "Servidor não pode encontrar o objeto requisitado" }, "login": "Entrar", "logout": "Sair", "not_found": { "back_home": "Voltar para o início", "not_found": "Opa 404, quebramos alguma coisa, ou você teve um erro de digitação :-/" }, "password": "Senha", "repo": { "activity": "Atividade", "add": "Adicionar repositório", "deploy_pipeline": { "variables": { "add": "Adicionar variável", "name": "Nome da variável", "value": "Valor da variável", "title": "Variáveis adicionais do pipeline", "desc": "Especifique variáveis adicionais a serem usadas em seu pipeline. As variáveis com o mesmo nome são substituídas.", "delete": "Excluir variável" }, "title": "Acionar a implantação para o pipeline atual #{pipelineId}", "enter_target": "Ambiente de destino para implantação", "trigger": "Implantação", "enter_task": "Tarefa de implantação" }, "enable": { "enable": "Habilitar", "enabled": "Já habilitado", "list_reloaded": "Lista de repositório recarregada", "reload": "Recarregar repositórios", "success": "Repositório habilitado", "disabled": "Desativado", "new_forge_repo": "novo repositório na forja", "stale_wp_repo": "repositório Woodpecker desatualizado", "conflict": "Conflito", "conflict_desc": "Este repositório foi recriado na forja com um novo ID, mas uma entrada desatualizada com o mesmo nome ainda existe no Woodpecker. Exclua o desatualizado para habilitar o novo, ou corrija o antigo.", "forge_repo_missing": "O repositório da forja está faltando!" }, "manual_pipeline": { "variables": { "add": "Adicionar variável", "desc": "Especifique variáveis adicionais a serem usadas em seu pipeline. As variáveis com o mesmo nome são substituídas.", "name": "Nome da variável", "value": "Valor da variável", "delete": "Excluir variável", "title": "Variáveis adicionais do pipeline" }, "title": "Executar um pipeline manual", "trigger": "Executar pipeline", "select_branch": "Selecionar branch", "show_pipelines": "Mostrar pipelines", "no_manual_workflows": "Nenhum fluxo de trabalho correspondente foi encontrado. Certifique-se de que pelo menos um fluxo de trabalho seja executado no evento manual." }, "not_allowed": "Você não está autorizado a acessar este repositório", "open_in_forge": "Abrir repositório na forja", "settings": { "general": { "allow_pr": { "allow": "Permitir pull requests", "desc": "Permite a execução de pipelines em pull requests." }, "cancel_prev": { "cancel": "Cancelar pipelines anteriores", "desc": "Permite cancelar pipelines pendentes e em execução do mesmo evento e contexto antes de iniciar o próximo pipeline." }, "general": "Projeto", "project": "Configurações de Projeto", "protected": { "desc": "Todas as pipeline necessitam de aprovação antes de serem executadas.", "protected": "Protegido" }, "save": "Salvar configurações", "success": "Configurações do projeto atualizadas", "timeout": { "minutes": "minutos", "timeout": "Tempo limite" }, "trusted": { "trusted": "Confiável", "desc": "Os contêineres de pipeline subjacente obtêm acesso a recursos escalonados, como a montagem de volumes.", "volumes": { "desc": "Os contêineres de pipeline podem montar volumes.", "volumes": "Volumes" }, "security": { "security": "Segurança", "desc": "Os contêineres de pipeline têm acesso a privilégios de segurança." }, "network": { "network": "Rede", "desc": "Os contêineres de pipeline têm acesso a privilégios de rede, como alteração de DNS." } }, "visibility": { "internal": { "desc": "Somente usuários autenticados na instância Woodpecker podem ver este projeto.", "internal": "Interno" }, "private": { "desc": "Somente você e outros donos do repositório podem ver este projeto.", "private": "Privado" }, "public": { "desc": "Qualquer usuário pode ver seu projeto sem estar logado.", "public": "Público" }, "visibility": "Visibilidade do projeto" }, "netrc_only_trusted": { "desc": "Plugins que obtêm acesso para credenciais netrc que podem ser usadas para clonar repositórios da forja ou fazer push para a forja.", "netrc_only_trusted": "Contêineres confiáveis personalizados" }, "pipeline_path": { "path": "Caminho do pipeline", "default": "Por padrão: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Caminho para a configuração do pipeline (por exemplo, {0}). As pastas devem terminar com {1}.", "desc_path_example": "meu/caminho/" }, "allow_deploy": { "allow": "Permitir implantações", "desc": "Permite implantações para pipelines bem-sucedidos. Todos os usuários com permissões de fazer push podem acioná-las, então use com cautela." } }, "not_allowed": "Você não está autorizado a acessar as configurações deste repositório", "secrets": { "add": "Adicionar secret", "created": "Secret criado", "delete": "Excluir secret", "delete_confirm": "Você realmente deseja excluir este secret?", "deleted": "Secret excluído", "edit": "Editar secret", "images": { "images": "Disponíveis para as seguintes imagens", "desc": "Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens" }, "name": "Nome", "save": "Salvar secret", "saved": "Secret salvo", "show": "Exibir secrets", "value": "Valor", "desc": "Os segredos podem ser passados em tempo de execução como variáveis de ambiente para etapas individuais no pipeline.", "secrets": "Segredos", "none": "Não há segredos ainda.", "events": { "events": "Disponível nos seguintes eventos", "pr_warning": "Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos." }, "plugins_only": "Disponível apenas para plugins" }, "settings": "Configurações", "crons": { "desc": "As tarefas Cron podem ser usados para acionar pipelines regularmente.", "delete": "Excluir cron", "crons": "Crons", "show": "Mostrar crons", "add": "Adicionar cron", "none": "Ainda não existem crons.", "save": "Salvar cron", "created": "Cron criado", "saved": "Cron salvo", "deleted": "Cron excluído", "next_exec": "Próxima execução", "not_executed_yet": "Ainda não foi executado", "run": "Executar agora", "branch": { "title": "Branch", "placeholder": "Branch (usa o branch padrão se estiver vazio)" }, "name": { "name": "Nome", "placeholder": "Nome do trabalho cron" }, "schedule": { "title": "Programação (com base no UTC)", "placeholder": "Programação" }, "edit": "Editar cron", "enabled": "Habilitado" }, "actions": { "repair": { "repair": "Reparar repositório", "success": "Repositório reparado" }, "delete": { "confirm": "Todos os dados serão perdidos após essa ação!\n\nVocê realmente deseja continuar?", "delete": "Excluir repositório", "success": "Repositório excluído" }, "actions": "Ações", "disable": { "disable": "Desativar repositório", "success": "Repositório desativado" }, "enable": { "enable": "Ativar repositório", "success": "Repositório ativado" } }, "registries": { "desc": "As credenciais de registries podem ser adicionadas para usar imagens privadas para o seu pipeline.", "created": "Credenciais de registry criadas", "saved": "Credenciais de registry salvas", "registries": "Registries", "credentials": "Credenciais de registry", "show": "Mostrar registries", "add": "Adicionar registry", "none": "Ainda não há credenciais de registry.", "save": "Salvar registry", "deleted": "Credenciais de registry excluídas", "address": { "address": "Endereço", "placeholder": "Endereço de registry (por exemplo, docker.io)" }, "edit": "Editar registry", "delete": "Excluir registry" }, "badge": { "badge": "Emblema", "type": "Sintaxe", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "branch": "Branch", "events": "Eventos", "workflow": "Fluxo de trabalho", "step": "Etapa" } }, "user_none": "Esta organização/usuário ainda não tem projeto", "pipeline": { "protected": { "awaits": "Esse pipeline está aguardando a aprovação de um mantenedor!", "review": "Revisar alterações", "approve": "Aprovar", "decline": "Recusar", "declined": "Esse pipeline foi recusado!", "approve_success": "Pipeline aprovado", "decline_success": "Pipeline recusado" }, "status": { "started": "iniciado", "status": "Status: {status}", "blocked": "bloqueado", "pending": "pendente", "running": "executando", "skipped": "omitido", "success": "sucesso", "declined": "recusado", "error": "erro", "failure": "falha", "killed": "finalizado", "canceled": "cancelada" }, "we_got_some_errors": "Oh não, ocorreu um erro!", "step_not_started": "Essa etapa ainda não foi iniciada.", "log_download_error": "Ocorreu um erro ao fazer o download do arquivo de registro", "actions": { "log_auto_scroll_off": "Desabilitar rolagem automática", "cancel": "Cancelar", "restart": "Reiniciar", "canceled": "Essa etapa foi cancelada.", "cancel_success": "Pipeline cancelado", "deploy": "Implantação", "restart_success": "Pipeline reiniciado", "log_download": "Baixar", "log_auto_scroll": "Habilitar rolagem automática", "log_delete": "Excluir", "skipped": "Essa etapa foi ignorada." }, "tasks": "Tarefas", "config": "Configuração", "files": "Arquivos alterados", "no_files": "Nenhum arquivo foi alterado.", "no_pipelines": "Nenhum pipelines foi iniciado ainda.", "event": { "push": "Push", "tag": "Tag", "pr": "Pull Request", "deploy": "Implantação", "cron": "Cron", "manual": "Manual", "release": "Release", "pr_closed": "Pull request mesclada/fechada", "pr_metadata": "Metadados da pull request alterados" }, "errors": "Erros", "warnings": "Avisos", "show_errors": "Mostrar erros", "no_pipeline_steps": "Não há etapas de pipeline disponíveis!", "pipelines_for": "Pipelines para branch \"{branch}\"", "pipelines_for_pr": "Pipelines para o pull request #{index}", "exit_code": "Código de saída {exitCode}", "loading": "Carregando…", "pipeline": "Pipeline #{pipelineId}", "log_title": "Registros de etapas", "no_logs": "Nenhum registro", "created": "Criado: {created}", "duration": "Duração do pipeline: {duration}", "debug": { "download_metadata": "Download dos metadados", "metadata_exec_title": "Executar novamente o pipeline localmente", "metadata_exec_desc": "Faz download dos metadados deste pipeline para executá-lo localmente. Isso permite corrigir problemas e testar alterações antes de enviá-las. O Woodpecker CLI deve ser instalado localmente na mesma versão do servidor.", "metadata_download_error": "Erro ao fazer download dos metadados", "metadata_download_successful": "Download dos metadados bem-sucedido", "no_permission": "Você não tem permissão para acessar as informações de depuração", "title": "Depuração" }, "log_delete_confirm": "Você realmente deseja excluir os registros de etapas?", "log_delete_error": "Ocorreu um erro ao excluir os registros de etapas", "view": "Ver pipeline", "cancel_info": { "superseded_by": "Substituído por #{pipelineId}", "canceled_by_user": "Cancelado por {user}", "canceled_by_step": "Cancelada por causa de {step}" }, "load_more": "Carregar mais", "version": "A versão do Woodpecker na qual este pipeline foi executado.", "version_header": "Versão do Woodpecker" }, "branches": "Branches", "pull_requests": "Pull requests", "visibility": { "private": { "private": "Privado", "desc": "Apenas você e outros proprietários do repositório podem ver este projeto." }, "visibility": "Visibilidade do projeto", "public": { "public": "Público", "desc": "Qualquer pessoa pode ver seu projeto sem estar autenticado." }, "internal": { "desc": "Apenas usuários autenticados da instância Woodpecker podem ver este projeto.", "internal": "Interno" } } }, "repos": "Repos", "repositories": { "title": "Repositórios", "all": { "title": "Todos os repositórios", "desc": "Repositórios ordenados pela última criação de pipeline" }, "last": { "title": "Última visita", "desc": "Repositórios visitados mais recentemente classificados por tempo de acesso" } }, "search": "Buscar…", "time": { "not_started": "não iniciado ainda", "template": "DD.MM.YYYY, HH:mm z", "weeks_short": "sem", "days_short": "d", "hours_short": "h", "min_short": "min", "sec_short": "seg", "just_now": "agora mesmo" }, "unknown_error": "Ocorreu um erro desconhecido", "url": "URL", "username": "Nome de usuário", "welcome": "Bem-vindo ao Woodpecker", "admin": { "settings": { "queue": { "stats": { "completed_count": "Tarefas concluídas", "worker_count": "Livre", "running_count": "Executando", "pending_count": "Pendente", "waiting_on_deps_count": "Aguardando dependências" }, "queue": "Em fila", "desc": "Tarefas aguardando para serem executadas pelos agentes.", "pause": "Pausa", "resume": "Retomar", "paused": "A fila está em pausa", "resumed": "A fila foi retomada", "tasks": "Tarefas", "task_running": "A tarefa está em execução", "task_pending": "Tarefa pendente", "task_waiting_on_deps": "A tarefa está aguardando as dependências", "agent": "agente", "waiting_for": "esperando por" }, "users": { "show": "Mostrar usuários", "delete_confirm": "Você realmente deseja excluir esse usuário? Isso também excluirá todos os repositórios pertencentes a esse usuário.", "created": "Usuário criado", "edit_user": "Editar usuário", "users": "Usuários", "desc": "Usuários registrados para este servidor.", "login": "Login", "email": "Email", "avatar_url": "URL do avatar", "save": "Salvar usuário", "cancel": "Cancelar", "add": "Adicionar usuário", "none": "Ainda não há usuários.", "deleted": "Usuário excluído", "saved": "Usuário salvo", "admin": { "admin": "Admin", "placeholder": "O usuário é um administrador" }, "delete_user": "Excluir usuário" }, "orgs": { "delete_org": "Excluir organização", "delete_confirm": "Você deseja realmente excluir essa organização? Isso também excluirá todos os repositórios pertencentes a essa organização.", "orgs": "Organizações", "desc": "Organizações proprietárias de repositórios neste servidor.", "none": "Ainda não existem organizações.", "org_settings": "Configurações da organização", "deleted": "Organização excluída", "view": "Ver organização" }, "secrets": { "deleted": "Segredo global excluído", "images": { "desc": "Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens", "images": "Disponível para as seguintes imagens" }, "secrets": "Segredos", "desc": "Os segredos globais podem ser usados nos pipelines de todos os repositórios.", "warning": "Esses segredos estão disponíveis para todos os usuários.", "none": "Ainda não há segredos globais.", "add": "Adicionar segredo", "save": "Salvar segredo", "show": "Mostrar segredos", "name": "Nome", "value": "Valor", "created": "Segredo global criado", "saved": "Segredo global salvo", "plugins_only": "Disponível apenas para plugins", "events": { "events": "Disponível nos seguintes eventos", "pr_warning": "Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos." } }, "agents": { "show": "Mostrar agentes", "name": { "name": "Nome", "placeholder": "Nome do agente" }, "no_schedule": { "placeholder": "Impedir agente de assumir novas tarefas", "name": "Desativar agente" }, "delete_confirm": "Você realmente deseja excluir esse agente? Ele não conseguirá mais se conectar ao servidor.", "agents": "Agentes", "desc": "Agentes registrados neste servidor.", "none": "Ainda não há agentes.", "id": "ID", "add": "Adicionar agente", "save": "Salvar agente", "created": "Agente criado", "saved": "Agente salvo", "deleted": "Agente excluído", "token": "Token", "platform": { "platform": "Plataforma", "badge": "plataforma" }, "backend": { "backend": "Backend", "badge": "backend" }, "capacity": { "capacity": "Capacidade", "desc": "A quantidade máxima de pipelines paralelos executados por esse agente.", "badge": "capacidade" }, "version": "Versão", "last_contact": { "last_contact": "Último contato", "badge": "último contato" }, "never": "Nunca", "edit_agent": "Editar agente", "delete_agent": "Excluir agente", "org": { "badge": "org" }, "custom_labels": { "custom_labels": "Etiquetas personalizadas", "desc": "As etiquetas personalizadas definidas pelo administrador do agente na inicialização do agente." } }, "settings": "Configurações administrativas", "not_allowed": "Você não tem permissão para acessar as configurações do servidor", "repos": { "repos": "Repositórios", "desc": "Repositórios que estão ou estavam ativados neste servidor.", "none": "Ainda não há repositórios.", "view": "Ver repositório", "settings": "Configurações do repositório", "disabled": "Desativado", "repair": { "repair": "Reparar todos", "success": "Repositórios reparados" } }, "registries": { "desc": "Credenciais de registry global podem ser adicionadas para usar imagens privadas para todos os pipelines.", "warning": "Essas credenciais de registry estão disponíveis para todos os usuários." } } }, "org": { "settings": { "secrets": { "secrets": "Segredos", "desc": "Os segredos da organização podem ser usados no pipeline de todos os repositórios desta organização.", "images": { "desc": "Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens", "images": "Disponível para as seguintes imagens" }, "events": { "pr_warning": "Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos.", "events": "Disponível nos seguintes eventos" }, "none": "Ainda não há segredos de organização.", "add": "Adicionar segredo", "save": "Salvar segredo", "show": "Mostrar segredos", "name": "Nome", "value": "Valor", "deleted": "Segredo da organização excluído", "created": "Segredo da organização criado", "saved": "Segredo da organização salvo", "plugins_only": "Disponível apenas para plugins" }, "settings": "Configurações", "not_allowed": "Você não tem permissão para acessar as configurações desta organização", "agents": { "desc": "Agentes registrados para esta organização." }, "registries": { "desc": "As credenciais de registry da organização podem ser adicionados para usar imagens privadas para todos os pipelines de uma organização." } } }, "api": "API", "empty_list": "Nenhuma {entity} encontrada!", "pipeline_feed": "Relatório de atividades de pipeline", "user": { "settings": { "secrets": { "desc": "Os segredos do usuário podem ser usados nos pipeline de todos os repositórios do usuário.", "images": { "desc": "Lista de imagens em que esse segredo está disponível; deixe em branco para permitir todas as imagens", "images": "Disponível para as seguintes imagens" }, "secrets": "Segredos", "none": "Ainda não há segredos de usuário.", "add": "Adicionar segredo", "save": "Salvar segredo", "show": "Mostrar segredos", "name": "Nome", "value": "Valor", "deleted": "Segredo do usuário excluído", "created": "Segredo de usuário criado", "saved": "Segredo do usuário salvo", "plugins_only": "Disponível apenas para plugins", "events": { "events": "Disponível nos seguintes eventos", "pr_warning": "Tenha cuidado com essa opção, pois um agente mal-intencionado pode enviar um pull request malicioso que expõe seus segredos." } }, "api": { "desc": "Token de acesso pessoal e uso da API", "token": "Token de acesso pessoal", "shell_setup_before": "Execute as etapas de configuração do shell antes", "api": "API", "shell_setup": "Configuração do shell", "api_usage": "Exemplo de uso da API", "cli_usage": "Exemplo de uso da CLI", "dl_cli": "Download da CLI", "reset_token": "Redefinir token", "swagger_ui": "Swagger UI" }, "settings": "Configurações do usuário", "general": { "general": "Conta", "language": "Idioma", "theme": { "theme": "Tema", "light": "Claro", "dark": "Escuro", "auto": "Auto" } }, "registries": { "desc": "Credenciais de registry de usuário podem ser adicionadas para usar imagens privadas para todos os pipelines pessoais." }, "cli_and_api": { "cli_usage": "Exemplo de uso da CLI", "download_cli": "Baixar CLI", "reset_token": "Redefinir token", "cli_and_api": "CLI & API", "desc": "Uso de token de acesso pessoal, CLI e API", "token": "Token de acesso pessoal", "api_usage": "Exemplo de uso da API", "swagger_ui": "Swagger UI" }, "agents": { "desc": "Os agentes registrados para os repositórios da sua conta." } }, "oauth_error": "Erro ao autenticar no provedor OAuth", "internal_error": "Ocorreu um erro interno", "access_denied": "Você não tem permissão para fazer login" }, "update_woodpecker": "Atualize sua instância do Woodpecker para {0}", "default": "padrão", "info": "Info", "running_version": "Você está executando Woodpecker {0}", "global_level_secret": "segredo global", "org_level_secret": "segredo da organização", "registries": { "add": "Adicionar registry", "save": "Salvar registry", "registries": "Registries", "saved": "Credenciais de registry salvas", "deleted": "Credenciais de registry excluídas", "view": "Ver registry", "edit": "Editar registry", "delete": "Excluir registry", "delete_confirm": "Você realmente deseja excluir esse registry?", "created": "Credenciais de registry criadas", "desc": "Credenciais de registry podem ser adicionadas para usar imagens privadas para pipelines.", "credentials": "Credenciais de registry", "none": "Ainda não há credenciais de registry.", "address": { "address": "Endereço", "desc": "Endereço de registry (p.ex., docker.io)" }, "show": "Mostrar registries" }, "login_to_cli_description": "Se continuar, você será conectado à CLI.", "cli_login_failed": "Login na CLI falhou", "secrets": { "add": "Adicionar segredo", "plugins": { "images": "Disponível apenas para os seguintes plugins", "desc": "Lista de imagens de plugins onde este segredo está disponível. Deixe em branco para permitir todos os plugins e etapas normais." }, "events": { "events": "Disponível apenas nos eventos a seguir", "warning": "Expor segredos para pull requests pode permitir que pessoas mal-intencionadas roubem seus segredos com um pull request malicioso." }, "secrets": "Segredos", "none": "Ainda não há segredos.", "value": "Valor", "edit": "Editar segredo", "save": "Salvar segredo", "show": "Mostrar segredos", "name": "Nome", "deleted": "Segredo excluído", "delete_confirm": "Você realmente deseja excluir este segredo?", "created": "Segredo criado", "saved": "Segredo salvo", "desc": "Segredos podem ser usados em todos os pipelines deste repositório.", "delete": "Excluir segredo", "note": "Nota" }, "require_approval": { "allowed_users": { "allowed_users": "Usuários permitidos", "desc": "Os pipelines criados pelos usuários listados nunca exigem aprovação." }, "pull_requests": "Todas pull requests", "none": "Nenhum", "require_approval_for": "Requisitos para aprovação", "none_desc": "Todo evento aciona pipelines, incluindo pull requests. Essa configuração pode ser perigosa e é recomendada apenas para instâncias privadas.", "forks": "Pull request de forks", "desc": "Evite que pipelines maliciosos exponham segredos ou executem tarefas prejudiciais aprovando-as antes da execução.", "all_events": "Todos eventos da forja" }, "access_denied": "Você não tem permissão para acessar esta instância", "no_search_results": "Nenhum resultado encontrado", "invalid_state": "O estado de OAuth é inválido", "org_access_denied": "Você não tem permissão para acessar esta organização", "login_to_cli": "Acessar com CLI", "abort": "Abortar", "cli_login_success": "Login na CLI bem-sucedido", "settings": "Configurações", "oauth_error": "Erro ao autenticar no provedor de OAuth", "return_to_cli": "Agora você pode fechar esta aba e retornar à CLI.", "internal_error": "Ocorreu um erro interno", "registration_closed": "O registro está fechado", "login_with": "Acessar com {forge}", "cli_login_denied": "Login na CLI negado", "forges": "Forjas", "add_forge": "Adicionar forja", "show_forges": "Mostrar forjas", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Complemento", "forge_type": "Tipo de forja", "oauth_host": "Host de OAuth", "public_only_desc": "Mostra apenas repositórios públicos.", "public_only": "Públicos apenas", "git_username": "Nome de usuário Git", "git_username_desc": "Nome de usuário do usuário Git.", "git_password": "Senha Git", "git_password_desc": "Senha ou token de acesso pessoal do usuário Git.", "executable": "Executável", "save": "Salvar", "add": "Adicionar", "skip_verify": "Pular verificação SSL", "forge_managed_by_env": "A forja principal é gerenciada por variáveis de ambiente. Quaisquer alterações nesta forja serão revertidas na reinicialização.", "oauth_redirect_uri": "URI de redirecionamento de OAuth", "forge_created": "Forja criada", "advanced_options": "Opções avançadas", "forge_deleted": "Forja excluída", "edit_forge": "Editar forja", "delete_forge": "Excluir forja", "no_forges": "Não há forja ainda.", "use_this_redirect_uri_to_create": "Use este URI de redirecionamento para criar ou atualizar o aplicativo OAuth. Acesse {0} e configure o aplicativo OAuth.", "developer_settings": "configurações do desenvolvedor", "public_url_for_oauth_if": "URL pública para OAuth se diferente da URL ({0})", "forge_saved": "Forja salva", "forges_desc": "Configura forjas hospedando repositórios para os quais Woodpecker deve ser executado.", "executable_desc": "Caminho do executável do complemento.", "forge_delete_confirm": "Você realmente deseja excluir esta forja? Isso também excluirá todos os repositórios, usuários e pipelines relacionados a esta forja.", "oauth_client_id": "ID de cliente OAuth", "oauth_client_secret": "Segredo de cliente OAuth", "merge_ref": "Ref da mesclagem", "leave_empty_to_keep_current_value": "Deixe em branco para manter o valor", "merge_ref_desc": "Referência a ser usada para mesclar a base. Isso é usado para determinar a diferença entre pull requests.", "skip_verify_desc": "Pula verificação SSL para a conexão API. Isso não é recomendado para uso em produção.", "login_to_woodpecker_with": "Entrar no Woodpecker com", "fullscreen": "Tela cheia", "exit_fullscreen": "Sair de tela cheia", "oauth_redirect_url": "URL de redirecionamento de OAuth", "use_this_redirect_url_to_create": "Use esta URL de redirecionamento para criar ou atualizar o aplicativo OAuth.", "developer_settings_to_create": "Vá para {0} e configure o aplicativo OAuth.", "weblate": "nosso Weblate", "help_translating": "Você pode ajudar a traduzir Woodpecker para o seu idioma em {0}.", "extensions": "Extensões", "extensions_description": "Extensões são serviços HTTP que podem ser chamados pelo Woodpecker em vez de usar os nativos.", "extension_endpoint_placeholder": "p.ex., https://example.com/api", "config_extension_endpoint": "Endpoint da extensão de configuração", "extensions_signatures_public_key": "Chave pública para assinaturas", "extensions_signatures_public_key_description": "Esta chave pública deve ser usada por suas extensões para verificar se o chamadas de webhook do Woodpecker.", "extensions_configuration_saved": "Configuração de extensões salva", "disabled": "Desativado", "config_extension_exclusive": "Exclusiva", "config_extension_exclusive_desc": "Se habilitada, essa opção ignorará todas as outras formas de obter configurações, incluindo a forja.", "global_level_registry": "registry global", "org_level_registry": "registry de organização", "registry_extension_endpoint": "Endpoint da extensão de registry", "secret_extension_endpoint": "Endpoint da extensão de segredos", "secret_extension_netrc": "Incluir cerdenciais de netrc", "secret_extension_netrc_desc": "Envia as credenciais de netrc da forja para a extensão de segredos.", "extension_netrc": "Incluir credenciais netrc", "extension_netrc_desc": "Envia credenciais netrc da forja para a extensão." } ================================================ FILE: web/src/assets/locales/ru.json ================================================ { "admin": { "settings": { "agents": { "add": "Добавить обработчик", "agents": "Обработчики", "backend": { "backend": "Бэкенд", "badge": "бэкенд" }, "capacity": { "badge": "мощность", "capacity": "Мощность", "desc": "Максимальное количество конвейеров, выполняемых параллельно этим обработчиком." }, "created": "Обработчик успешно добавлен", "delete_agent": "Удалить обработчик", "delete_confirm": "Вы действительно хотите удалить этот обработчик? Он больше не сможет подключаться к серверу.", "deleted": "Обработчик успешно удалён", "desc": "Обработчики, зарегистрированные на этом сервере.", "edit_agent": "Редактировать обработчик", "id": "ID", "last_contact": { "badge": "доступен", "last_contact": "Последняя связь" }, "name": { "name": "Название", "placeholder": "Название обработчика" }, "never": "Никогда", "no_schedule": { "name": "Отключить обработчик", "placeholder": "Запретить обработчику получать новые задачи" }, "none": "Обработчиков пока нет.", "platform": { "badge": "платформа", "platform": "Платформа" }, "save": "Сохранить обработчик", "saved": "Обработчик сохранён", "show": "Показать обработчики", "token": "Токен", "version": "Версия", "org": { "badge": "орг" }, "custom_labels": { "custom_labels": "Пользовательские Метки", "desc": "Пользовательские метки, установленные администратором обработчика при его запуске." } }, "not_allowed": "У вас нет прав доступа к настройкам сервера", "orgs": { "delete_confirm": "Вы действительно хотите удалить эту организацию? При этом также будут удалены все репозитории, принадлежащие этой организации.", "delete_org": "Удалить организацию", "deleted": "Организация удалена", "desc": "Организации, владеющие репозиториями на этом сервере.", "none": "Организаций пока нет.", "org_settings": "Настройки организации", "orgs": "Организации", "view": "Просмотр организации" }, "queue": { "agent": "обработчик", "desc": "Задачи, ожидающие выполнения обработчиками.", "pause": "Приостановить", "paused": "Очередь приостановлена", "queue": "Очередь", "resume": "Продолжить", "resumed": "Очередь возобновлена", "stats": { "completed_count": "Завершённые задачи", "pending_count": "Ожидает", "running_count": "Выполняется", "waiting_on_deps_count": "Ожидает зависимостей", "worker_count": "Свободно" }, "task_pending": "Задача ожидает", "task_running": "Задача выполняется", "task_waiting_on_deps": "Задача ожидает завершения выполнения зависимостей", "tasks": "Задачи", "waiting_for": "в ожидании" }, "repos": { "desc": "Репозитории, когда-либо включавшиеся на этом сервере.", "disabled": "Отключено", "none": "Репозиториев пока нет.", "repair": { "repair": "Исправить все", "success": "Репозитории исправлены" }, "repos": "Репозитории", "settings": "Настройки репозитория", "view": "Просмотр репозитория" }, "secrets": { "add": "Создать секрет", "created": "Глобальный секрет создан", "deleted": "Глобальный секрет удалён", "desc": "Глобальные секреты могут использоваться во всех конвейерах всех репозиториев.", "events": { "events": "Доступен для следующих событий", "pr_warning": "Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты." }, "images": { "desc": "Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы", "images": "Доступен только для этих образов" }, "name": "Название", "none": "Тут пока нет глобальных секретов.", "plugins_only": "Доступен только для расширений", "save": "Сохранить секрет", "saved": "Глобальный секрет сохранён", "secrets": "Секреты", "show": "Показать секрет", "value": "Значение", "warning": "Эти секреты доступны всем пользователям." }, "settings": "Настройки", "users": { "add": "Добавить пользователя", "admin": { "admin": "Администратор", "placeholder": "Пользователь является администратором" }, "avatar_url": "URL аватара", "cancel": "Отмена", "created": "Пользователь успешно создан", "delete_confirm": "Вы действительно хотите удалить этого пользователя? При этом также будут удалены все репозитории, принадлежащие этому пользователю.", "delete_user": "Удалить пользователя", "deleted": "Пользователь успешно удалён", "desc": "Пользователи, зарегистрированные на этом сервере.", "edit_user": "Изменить пользователя", "email": "Почта", "login": "Вход в систему", "none": "Пользователей пока нет.", "save": "Сохранить пользователя", "saved": "Пользователь сохранён", "show": "Показать пользователей", "users": "Пользователи" }, "registries": { "desc": "Можно добавить глобальные учётные данные реестра, чтобы иметь возможность использовать частные образы во всех конвейерах.", "warning": "Эти учётные данные реестра доступны всем пользователям." } } }, "api": "API", "back": "Назад", "cancel": "Отменить", "default": "по умолчанию", "docs": "Документация", "documentation_for": "Документация о «{topic}»", "errors": { "not_found": "Серверу не удалось найти запрошенный объект" }, "global_level_secret": "глобальный секрет", "info": "Информация", "login": "Вход", "logout": "Выйти", "not_found": { "back_home": "Вернуться на главную", "not_found": "Ошибка 404. Проверьте, что ввели адрес правильно :-/" }, "org": { "settings": { "not_allowed": "У вас нет доступа к настройкам этой организации", "secrets": { "add": "Создать секрет", "created": "Секрет организации успешно добавлен", "deleted": "Секрет организации был удалён", "desc": "Секреты организации могут использоваться в конвейерах всех репозиториев, принадлежащих организации.", "events": { "events": "Доступен для следующих событий", "pr_warning": "Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты." }, "images": { "desc": "Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы", "images": "Доступен только для этих образов" }, "name": "Название", "none": "Тут пока нет секретов организации.", "plugins_only": "Доступен только для расширений", "save": "Сохранить секрет", "saved": "Секрет организации успешно обновлён", "secrets": "Секреты", "show": "Показать секрет", "value": "Значение" }, "settings": "Настройки", "registries": { "desc": "Можно добавить учётные данные реестра для организации, чтобы иметь возможность использовать частные образы во всех конвейерах этой организации." }, "agents": { "desc": "Обработчики, зарегистрированные для этой организации." } } }, "org_level_secret": "секрет организации", "password": "Пароль", "pipeline_feed": "Состояние конвейеров", "repo": { "activity": "Активность", "add": "Подключить репозиторий", "branches": "Ветви", "deploy_pipeline": { "enter_target": "Целевое окружение для развёртывания", "title": "Вызвать развёртывание для текущего конвейера #{pipelineId}", "trigger": "Развернуть", "variables": { "add": "Добавить переменную", "desc": "Укажите дополнительные переменные для использования в конвейере. Переменные с совпадающими именами будут перезаписаны.", "name": "Имя переменной", "title": "Дополнительные переменные для конвейера", "value": "Значение переменной", "delete": "Удалить переменную" }, "enter_task": "Задача на развёртывание" }, "enable": { "disabled": "Отключено", "enable": "Подключить", "enabled": "Уже подключен", "list_reloaded": "Обновить список репозиториев", "reload": "Обновить репозитории", "success": "Репозиторий подключен", "new_forge_repo": "новый репозиторий на платформе", "stale_wp_repo": "устаревший репозиторий Woodpecker", "conflict": "Конфликт", "forge_repo_missing": "Репозиторий не найден в платформе!", "conflict_desc": "Этот репозиторий был пересоздан в платформе с новым ID, но в Woodpecker всё ещё существует устаревшая запись с тем же именем. Удалите устаревшую запись, чтобы активировать новую, либо восстановите старую." }, "manual_pipeline": { "select_branch": "Выберите ветвь", "title": "Запустить конвейер вручную", "trigger": "Запустить конвейер", "variables": { "add": "Добавить переменную", "desc": "Укажите дополнительные переменные для использования в конвейере. Переменные с совпадающими именами будут перезаписаны.", "name": "Имя переменной", "title": "Дополнительные переменные для конвейера", "value": "Значение переменной", "delete": "Удалить переменную" }, "show_pipelines": "Показать конвейеры", "no_manual_workflows": "Не найдено подходящих рабочих процессов. Убедитесь, что хотя бы один процесс можно запустить вручную событием manual." }, "not_allowed": "У вас нет прав для доступа к этому репозиторию", "open_in_forge": "Открыть репозиторий в платформе разработки", "pipeline": { "actions": { "cancel": "Отменить", "cancel_success": "Конвейер отменён", "canceled": "Этот шаг был отменён.", "deploy": "Развёртывание", "log_auto_scroll": "Включить автоматическую прокрутку", "log_auto_scroll_off": "Отключить автоматическую прокрутку", "log_download": "Скачать", "restart": "Перезапустить", "restart_success": "Конвейер перезапущен", "log_delete": "Удалить", "skipped": "Этот шаг был пропущен." }, "config": "Конфигурация", "errors": "Ошибки", "event": { "cron": "Задание cron", "deploy": "Развёртывание (деплой)", "manual": "Ручной запуск", "pr": "Запросы на слияние", "push": "Новый коммит", "tag": "Тег", "pr_closed": "Запрос на слияние удовлетворён/закрыт", "release": "Релиз", "pr_metadata": "Изменены метаданные запроса на слияние" }, "exit_code": "Код завершения {exitCode}", "files": "Изменённые файлы", "loading": "Загрузка…", "log_download_error": "Произошла ошибка при скачивании файла журнала", "log_title": "Журнал шага", "no_files": "Никакие файлы не были изменены.", "no_pipeline_steps": "Нет доступных шагов конвейера!", "no_pipelines": "Ни один конвейер ещё не запущен.", "pipeline": "Конвейер №{pipelineId}", "pipelines_for": "Конвейеры для ветви «{branch}»", "pipelines_for_pr": "Конвейер для запроса на слияние №{index}", "protected": { "approve": "Подтвердить", "approve_success": "Конвейер подтверждён", "awaits": "Конвейер ожидает подтверждения разработчиком!", "decline": "Отклонить", "decline_success": "Конвейер отклонён", "declined": "Этот конвейер был отклонён!", "review": "Обзор изменений" }, "show_errors": "Показать ошибки", "status": { "blocked": "заблокирован", "declined": "отклонён", "error": "ошибка", "failure": "провален", "killed": "принудительно завершён", "pending": "ожидает", "running": "выполняется", "skipped": "пропущен", "started": "запущен", "status": "Состояние: {status}", "success": "успешно выполнен", "canceled": "отменено" }, "step_not_started": "Этот шаг ещё не запущен.", "tasks": "Задачи", "warnings": "Предупреждения", "we_got_some_errors": "О нет, возникла ошибка!", "no_logs": "Нет записей журнала", "created": "Создано: {created}", "duration": "Время работы конвейера: {duration}", "log_delete_confirm": "Вы действительно хотите удалить журналы этого шага?", "log_delete_error": "При удалении журналов шага произошла ошибка", "debug": { "metadata_exec_desc": "Скачайте метаданные конвейера, чтобы запустить его на своей системе. Это даст вам возможность отладить проблемы и проверить изменения перед их публикацией. У Вас должен быть установлен `woodpecker-cli`, версия которого совпадает с версией Woodpecker на сервере.", "title": "Отладка", "download_metadata": "Скачать метаданные", "metadata_download_successful": "Метаданные успешно загружены", "metadata_download_error": "Ошибка загрузки метаданных", "no_permission": "У вас нет доступа к отладочной информации", "metadata_exec_title": "Перезапустить конвейер локально" }, "view": "Посмотреть конвейер", "cancel_info": { "canceled_by_user": "Отменено {user}", "superseded_by": "Заменено на #{pipelineId}", "canceled_by_step": "Отменено из-за шага {step}" }, "load_more": "Загрузить ещё", "version_header": "Версия Woodpecker", "version": "Версия Woodpecker, на которой был выполнен этот конвейер." }, "pull_requests": "Запросы на слияние", "settings": { "actions": { "actions": "Действия", "delete": { "confirm": "Все данные будут потеряны после этого действия!\n\nВы действительно хотите продолжить?", "delete": "Удалить репозиторий", "success": "Репозиторий удалён" }, "disable": { "disable": "Отключить репозиторий", "success": "Репозиторий отключен" }, "enable": { "enable": "Включить репозиторий", "success": "Репозиторий включён" }, "repair": { "repair": "Восстановить репозиторий", "success": "Репозиторий восстановлен" } }, "badge": { "badge": "Бейдж", "branch": "Ветвь", "type": "Синтаксис", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL", "events": "События", "workflow": "Рабочий процесс", "step": "Шаг" }, "crons": { "add": "Добавить задачу cron", "branch": { "placeholder": "Ветвь (если пусто, используется ветвь по умолчанию)", "title": "Ветвь" }, "created": "Задача cron создана", "crons": "Задачи cron", "delete": "Удалить задачу cron", "deleted": "Задача cron удалена", "desc": "Задачи cron можно использовать для регулярного запуска конвейеров.", "edit": "Редактировать задачу cron", "name": { "name": "Название", "placeholder": "Имя задачи cron" }, "next_exec": "Следующий запуск", "none": "Пока нет ни одной задачи cron.", "not_executed_yet": "Ещё не запущено", "run": "Запустить сейчас", "save": "Сохранить задачу cron", "saved": "Задача cron сохранена", "schedule": { "placeholder": "Расписание", "title": "Расписание (по UTC)" }, "show": "Показать задачи cron", "enabled": "Включено" }, "general": { "allow_pr": { "allow": "Разрешить запросы на слияние", "desc": "Разрешить запуск конвейеров для запросов на слияние." }, "cancel_prev": { "cancel": "Принудительно завершить все предыдущие конвейеры", "desc": "Выбранные триггеры отменят ожидающие и запущенные конвейеры того же события, после чего будет запущен следующий конвейер." }, "general": "Проект", "netrc_only_trusted": { "desc": "Плагины, которые получат доступ к учётным данным netrc, которые можно использовать для клонирования или публикации кода на платформу.", "netrc_only_trusted": "Только доверенные плагины клонирования" }, "pipeline_path": { "default": "По умолчанию: .woodpecker/*.{'{yaml,yml}'} -> .woodpecker.yaml -> .woodpecker.yml", "desc": "Путь к конфигурации вашего конвейера (например: {0}). При указании директории путь должен заканчиваться символом {1}.", "desc_path_example": "мой/путь/", "path": "Конфигурация конвейера" }, "project": "Настройки проекта", "protected": { "desc": "Каждый конвейер должен быть проверен до начала выполнения.", "protected": "Защищён" }, "save": "Сохранить настройки", "success": "Настройки проекта обновлены", "timeout": { "minutes": "минуты", "timeout": "Время ожидания" }, "trusted": { "desc": "Доверенные конвейеры получат доступ к дополнительным возможностям (например, монтированию томов).", "trusted": "Доверенный", "network": { "network": "Сеть", "desc": "Контейнеры конвейера получат доступ к сетевым привилегиям, таким как изменение настроек DNS." }, "security": { "security": "Безопасность", "desc": "Контейнеры конвейера получат доступ к привилегиям безопасности." }, "volumes": { "volumes": "Томы", "desc": "Контейнеры конвейера получат доступ к привилегиям томов." } }, "visibility": { "internal": { "desc": "Только пользователи, вошедшие в систему, смогут видеть этот проект.", "internal": "Внутренний" }, "private": { "desc": "Только вы и другие владельцы этого репозитория смогут видеть его.", "private": "Приватный" }, "public": { "desc": "Любой незарегистрированный пользователь сможет увидеть этот проект.", "public": "Публичный" }, "visibility": "Видимость проекта" }, "allow_deploy": { "allow": "Разрешить развёртывание", "desc": "Разрешить развёртывание для успешных конвейеров. Будьте осторожны: их сможет запустить любой пользователь с правами push." } }, "not_allowed": "У вас нет доступа к настройкам этого репозитория", "registries": { "add": "Добавить реестр", "address": { "address": "Адрес", "placeholder": "Адрес реестра (например: docker.io)" }, "created": "Данные для доступа к реестру добавлены", "credentials": "Учётные данные для авторизации в реестре", "delete": "Удалить реестр", "deleted": "Данные для доступа к реестру удалены", "desc": "Можно добавить учетные данные для доступа к реестру, чтобы использовать приветные образы из этого реестра в конвейере.", "edit": "Изменить реестр", "none": "Пока тут нет учётных данных для доступа к реестрам.", "registries": "Реестры с образами", "save": "Сохранить реестр", "saved": "Данные для доступа к реестру сохранены", "show": "Показать реестры" }, "secrets": { "add": "Создать секрет", "created": "Секрет создан", "delete": "Удалить секрет", "delete_confirm": "Вы действительно хотите удалить этот секрет?", "deleted": "Секрет успешно удалён", "desc": "Секреты могут быть переданы отдельным этапам конвейера во время выполнения в качестве переменных окружения.", "edit": "Изменить секрет", "events": { "events": "Доступен для следующих событий", "pr_warning": "Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты." }, "images": { "desc": "Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы", "images": "Доступен только для этих образов" }, "name": "Название", "none": "Пока тут нет секретов.", "plugins_only": "Доступен только для расширений", "save": "Сохранить секрет", "saved": "Секрет успешно сохранён", "secrets": "Секреты", "show": "Показать секрет", "value": "Значение" }, "settings": "Настройки" }, "user_none": "Эта организация/пользователь не имеет ни одного проекта", "visibility": { "visibility": "Видимость проекта", "public": { "public": "Публичный", "desc": "Любой желающий сможет увидеть ваш проект, не входя в систему." }, "private": { "private": "Закрытый", "desc": "Проект доступен только вам и другим владельцам репозитория." }, "internal": { "internal": "Внутренний", "desc": "Проект доступен только вошедшим пользователям этого экземпляра Woodpecker." } } }, "repos": "Репозитории", "repositories": { "title": "Репозитории", "all": { "title": "Все репозитории", "desc": "Репозитории, отсортированные по времени последнего создания конвейера" }, "last": { "desc": "Последние посещённые репозитории, отсортированные по времени доступа", "title": "Последние посещённые" } }, "running_version": "Вы используете Woodpecker {0}", "search": "Поиск…", "time": { "days_short": "д.", "hours_short": "ч.", "min_short": "мин.", "not_started": "ещё не запускался", "sec_short": "сек.", "template": "D MMM, YYYY, HH:mm z", "weeks_short": "нед.", "just_now": "только что" }, "unknown_error": "Произошла неизвестная ошибка", "update_woodpecker": "Пожалуйста, обновите свой экземпляр Woodpecker до {0}", "url": "URL", "user": { "access_denied": "У вас нет прав для входа в систему", "internal_error": "Произошла внутренняя ошибка", "oauth_error": "Ошибка при аутентификации через OAuth провайдера", "settings": { "api": { "api": "API", "api_usage": "Пример использования API", "cli_usage": "Пример использования CLI", "desc": "Токен персонального доступа и использование API", "dl_cli": "Загрузить CLI", "reset_token": "Сбросить токен", "shell_setup": "Настройка оболочки", "shell_setup_before": "выполните шаги по настройке оболочки перед", "swagger_ui": "Интерфейс Swagger", "token": "Токен персонального доступа" }, "general": { "general": "Учётная запись", "language": "Язык", "theme": { "auto": "Авто", "dark": "Тёмная", "light": "Светлая", "theme": "Тема" } }, "secrets": { "add": "Добавить секрет", "created": "Секрет пользователя создан", "deleted": "Секрет пользователя удален", "desc": "Пользовательские секреты могут использоваться во всех репозиториях, принадлежащих пользователю.", "events": { "events": "Доступно на следующих событий", "pr_warning": "Пожалуйста, будьте осторожны с этой опцией, так как злоумышленник может отправить вредоносный запрос на слияние, который раскроет ваши секреты." }, "images": { "desc": "Список образов, для которых доступен этот секрет. Оставьте поле пустым, чтобы разрешить все образы", "images": "Доступно для следующих образов" }, "name": "Имя", "none": "Секретов пользователей пока нет.", "plugins_only": "Доступно только для плагинов", "save": "Сохранить секрет", "saved": "Секрет пользователя сохранен", "secrets": "Секреты", "show": "Показать секреты", "value": "Значение" }, "settings": "Настройки пользователя", "cli_and_api": { "token": "Персональный токен доступа", "cli_and_api": "Командная строка и API", "desc": "Персональный токен доступа, командная строка и API", "download_cli": "Скачать интерфейс командной строки", "cli_usage": "Пример использования командной строки", "api_usage": "Пример использования API", "reset_token": "Сбросить токен", "swagger_ui": "Интерфейс Swagger" }, "registries": { "desc": "Добавление учётных данных реестра для пользователя даст возможность использовать частные образы во всех конвейерах пользователя." }, "agents": { "desc": "Обработчики, зарегистрированные для репозиториев вашей учётной записи." } } }, "username": "Имя пользователя", "welcome": "Добро пожаловать в Woodpecker", "secrets": { "desc": "Секреты могут использоваться всеми конфейерами этого репозитория.", "save": "Сохранить секрет", "show": "Показать секреты", "name": "Имя", "value": "Значение", "delete_confirm": "Вы действительно хотите удалить этот секрет?", "deleted": "Секрет удалён", "created": "Секрет создан", "saved": "Секрет сохранён", "add": "Добавить секрет", "images": { "images": "Доступно следующим образам", "desc": "Список образов, которым доступен этот секрет; оставьте пустым, чтобы разрешить доступ всем образам." }, "secrets": "Секреты", "none": "Секретов пока нет.", "events": { "events": "Доступно следующим событиям", "pr_warning": "Пожалуйста, будьте осторожны с этой опцией: злоумышленник может раскрыть ваши секреты, отправив вредоносный запрос на слияние.", "warning": "Раскрытие секретов запросам на слияние может позволить злоумышленникам украсть ваши секреты с помощью вредоносного запроса." }, "edit": "Редактировать секрет", "delete": "Удалить секрет", "plugins": { "images": "Доступно только следующим плагинам", "desc": "Список образов плагинов, которым доступен этот секрет. Оставьте значение пустым, чтобы разрешить доступ для всем плагинам и для обычным шагам конвейера." }, "note": "Комментарий" }, "internal_error": "Произошла внутренняя ошибка", "registration_closed": "Регистрация закрыта", "registries": { "address": { "desc": "Адрес реестра (например, docker.io)", "address": "Адрес" }, "show": "Показать реестры", "save": "Сохранить реестр", "add": "Добавить реестр", "view": "Просмотр реестра", "none": "Учётных данных реестра пока нет.", "registries": "Реестры", "credentials": "Учётные данные реестра", "desc": "Можно добавить учётные данные реестра, чтобы использовать частные образы в конвейерах.", "edit": "Редактировать реестр", "delete": "Удалить реестр", "delete_confirm": "Вы действительно хотите удалить этот реестр?", "created": "Учётные данные реестра созданы", "saved": "Учётные данные реестра сохранены", "deleted": "Учётные данные реестра удалены" }, "login_to_cli": "Вход через командную строку", "abort": "Прервать", "cli_login_success": "Успешный вход в интерфейс командной строки", "cli_login_failed": "Вход в интерфейс командной строки не удался", "return_to_cli": "Теперь вы можете закрыть эту вкладку и вернуться в командную строку.", "settings": "Настройки", "login_to_cli_description": "Если вы продолжите, то войдёте в интерфейс командной строки.", "cli_login_denied": "Вход через командную строку запрещён", "invalid_state": "Некорректное состояние OAuth", "oauth_error": "Ошибка при аутентификации в провайдере OAuth", "access_denied": "Вам не разрешён доступ к этому экземпляру", "empty_list": "{entity} не найдены!", "login_with": "Вход через {forge}", "all_repositories": "Все репозитории", "no_search_results": "Результаты не найдены", "require_approval": { "forks": "Запрос на слияние из форка", "pull_requests": "Все запросы на слияние", "all_events": "Все события платформы", "none_desc": "Каждое событие инициирует выполнение конвейеров, включая запросы на слияние. Эта небезопасная настройка, рекомендуемая только для частных экземпляров Woodpecker.", "none": "Нет", "desc": "Предотвратить утечку конфиденциальных данных или выполнение вредоносных задач через ручное подтверждение перед началом выполнения.", "require_approval_for": "Требования подтверждения", "allowed_users": { "allowed_users": "Разрешённые пользователи", "desc": "Конвейеры, созданные перечисленными пользователями, не требуют подтверждения." } }, "org_access_denied": "У вас нет доступа к этой организации", "show_forges": "Показать платформы", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "addon": "Дополнение", "oauth_client_id": "OAuth Client ID", "public_only": "Только публичные", "public_only_desc": "Показывать только публичные репозитории.", "git_username": "Имя пользователя Git", "git_password": "Пароль Git", "executable": "Исполняемый файл", "executable_desc": "Путь к исполняемому файлу расширения.", "save": "Сохранить", "add": "Добавить", "skip_verify": "Пропустить проверку SSL", "forge_managed_by_env": "Основная платформа управляется переменными окружения. Любые изменения этой платформы будут отменены при перезапуске.", "oauth_redirect_uri": "URI перенаправления OAuth", "forge_created": "Платформа создана", "oauth_client_secret": "Секрет клиента OAuth", "merge_ref": "Ref слияния", "forge_deleted": "Платформа удалена", "edit_forge": "Редактировать платформу", "delete_forge": "Удалить платформу", "no_forges": "Платформ пока нет.", "developer_settings": "настройки разработчика", "public_url_for_oauth_if": "Публичная URL для OAuth, если отличается от URL ({0})", "forge_saved": "Платформа сохранена", "forges": "Платформы", "forge_type": "Тип платформы", "git_username_desc": "Имя для пользователя Git.", "skip_verify_desc": "Пропустить проверку SSL для подключения API. Не рекомендуется использовать в рабочей среде.", "add_forge": "Добавить платформу", "oauth_host": "Сервер OAuth", "advanced_options": "Расширенные настройки", "forge_delete_confirm": "Вы действительно хотите удалить эту платформу? Её удаление также повлечёт удаление привязанных к ней репозиториев, пользователей и конвейеров.", "git_password_desc": "Пароль или персональный токен доступа для пользователя Git.", "use_this_redirect_uri_to_create": "Используйте эту URI перенаправления для создания или обновления приложения OAuth. Перейдите на {0} и настройте приложение OAuth.", "leave_empty_to_keep_current_value": "Оставьте пустым, чтобы сохранить текущее значение", "forges_desc": "Настройте платформы c репозиториями, для которых требуется выполнение Woodpecker.", "merge_ref_desc": "Ref-основание для слияния. Используется для определения изменений в запросах на слияние.", "login_to_woodpecker_with": "Войти в Woodpecker через", "fullscreen": "Полноэкранный режим", "exit_fullscreen": "Выйти из полноэкранного режима", "oauth_redirect_url": "URL перенаправления OAuth", "weblate": "нашем Weblate", "help_translating": "Вы можете помочь с переводом Woodpecker на свой язык на {0}.", "use_this_redirect_url_to_create": "Используйте эту URL перенаправления для создания или обновления приложения OAuth.", "developer_settings_to_create": "Перейдите в {0} и настройте приложение OAuth.", "extensions": "Расширения", "extensions_description": "Расширения — это HTTP-службы, которые Woodpecker может вызывать вместо использования встроенных служб.", "extension_endpoint_placeholder": "например, https://example.com/api", "extensions_signatures_public_key": "Открытый ключ для подписей", "extensions_signatures_public_key_description": "Этот открытый ключ должен использоваться вашими расширениями для проверки запросов, отправляемых Woodpecker.", "extensions_configuration_saved": "Настройки расширений сохранены", "disabled": "Отключено", "config_extension_endpoint": "Конечная точка расширения конфигурации", "config_extension_exclusive_desc": "Отключает все другие способы получения конфигурации, в том числе от платформы.", "config_extension_exclusive": "Эксклюзивно", "registry_extension_endpoint": "Конечная точка расширения реестра", "global_level_registry": "глобальный реестр", "org_level_registry": "реестр организации", "secret_extension_endpoint": "Конечная точка расширения секретов", "secret_extension_netrc": "Включить учётные данные netrc", "secret_extension_netrc_desc": "Отправлять расширению учётные данные netrc платформы.", "extension_netrc_desc": "Передавать учётные данные netrc расширению.", "extension_netrc": "Включить учётные данные netrc" } ================================================ FILE: web/src/assets/locales/uk.json ================================================ { "admin": { "settings": { "not_allowed": "Ви не маєте права доступу до налаштувань сервера", "secrets": { "add": "Додати секрет", "created": "Створено глобальний секрет", "deleted": "Глобальну таємницю видалено", "desc": "Глобальні секрети можуть бути передані всім сховищам, окремим крокам конвеєра під час виконання як змінні середовища.", "events": { "events": "Доступно на наступних заходах", "pr_warning": "Будь ласка, будьте обережні з цією опцією, оскільки зловмисник може надіслати зловмисний запит, який розкриє ваші секрети." }, "images": { "desc": "Через кому список зображень, для яких доступний цей секрет, залишити порожнім, щоб дозволити всі зображення", "images": "Доступно для наступних зображень" }, "name": "Найменування", "none": "Глобальних секретів поки що не існує.", "save": "Зберегти секрет", "saved": "Глобальний секрет збережено", "secrets": "Секрети", "show": "Показати секрети", "value": "Значення", "warning": "Ці секрети будуть доступні для всіх користувачів сервера." }, "settings": "Налаштування", "agents": { "id": "ID", "name": { "name": "Назва" }, "platform": { "platform": "Платформа", "badge": "платформа" }, "version": "Версія", "never": "Ніколи" }, "queue": { "queue": "Черга", "tasks": "Завдання" } } }, "back": "Назад", "cancel": "Скасувати", "docs": "Документи", "documentation_for": "Документація для \"{topic}\"", "errors": { "not_found": "Серверу не вдалося знайти запитуваний об'єкт" }, "login": "Логін", "logout": "Вихід", "not_found": { "back_home": "Повертаємося додому", "not_found": "Ого 404, або ми щось зламали, або у вас помилка при наборі тексту :-/" }, "org": { "settings": { "not_allowed": "Ви не маєте права доступу до налаштувань цієї організації", "secrets": { "add": "Додати секрет", "created": "Секрет організації збережено", "deleted": "Організаційну таємницю видалено", "desc": "Секрети організації можуть бути передані всім окремим крокам конвеєра сховища організації під час виконання як змінні середовища.", "events": { "events": "Доступно на наступних заходах", "pr_warning": "Будь ласка, будьте обережні з цією опцією, оскільки зловмисник може надіслати зловмисний запит, який розкриє ваші секрети." }, "images": { "desc": "Через кому список зображень, для яких доступний цей секрет, залишити порожнім, щоб дозволити всі зображення", "images": "Доступно для наступних зображень" }, "name": "Найменування", "none": "Секретів організації поки що немає.", "save": "Зберегти секрет", "saved": "Секрет організації збережено", "secrets": "Секрети", "show": "Показати секрети", "value": "Значення" }, "settings": "Налаштування" } }, "password": "Пароль", "pipeline_feed": "Трубопровідна подача", "repo": { "activity": "Активність", "add": "Додати репозиторій", "branches": "Відділення", "deploy_pipeline": { "enter_target": "Цільове середовище розгортання", "variables": { "add": "Додати змінну", "desc": "Вкажіть додаткові змінні для використання у конвеєрі. Змінні з однаковими іменами буде перезаписано.", "title": "Додаткові змінні конвеєра", "delete": "Видалити змінну", "name": "Ім'я змінної", "value": "Змінне значення" }, "enter_task": "Завдання на розгортання", "title": "Запустити розгортання для поточного конвеєра #{pipelineId}", "trigger": "Розгорнути" }, "enable": { "enable": "Увімкнути", "enabled": "Вже увімкнено", "list_reloaded": "Оновлений список репозиторіїв", "reload": "Перезавантажити репозиторії", "success": "Репозиторій увімкнено", "disabled": "Вимкнено" }, "manual_pipeline": { "select_branch": "Оберіть відділення", "title": "Запустити ручний прогін трубопроводу", "trigger": "Запуск конвеєра", "variables": { "add": "Додати змінну", "desc": "Вкажіть додаткові змінні, які будуть використовуватися у вашому конвеєрі. Змінні з однаковими іменами перезаписуються.", "name": "Ім'я змінної", "title": "Додаткові змінні трубопроводу", "value": "Значення змінної", "delete": "Вилучити змінну" } }, "not_allowed": "Ви не маєте права доступу до цього сховища", "open_in_forge": "Відкрити репозиторій у системі керування версіями", "pipeline": { "actions": { "cancel": "Скасувати", "cancel_success": "Трубопровід скасовано", "canceled": "Цей крок було скасовано.", "log_auto_scroll": "Автоматична прокрутка вниз", "log_auto_scroll_off": "Вимкнути автоматичну прокрутку", "log_download": "Завантажити", "restart": "Перезапуск", "restart_success": "Трубопровід перезапущено" }, "config": "Конфіг", "event": { "cron": "Крон", "deploy": "Розгорнути", "manual": "Посібник", "pr": "Запит на вилучення", "push": "Натисни", "tag": "Тег" }, "exit_code": "код виходу {exitCode}", "files": "Змінені файли ({files})", "loading": "Загрузка…", "log_download_error": "Виникла помилка при завантаженні лог-файлу", "no_files": "Жодні файли не були змінені.", "no_pipeline_steps": "Сходинки трубопроводу відсутні!", "no_pipelines": "Жоден трубопровід ще не був запущений.", "pipeline": "Трубопровід #{pipelineId}", "pipelines_for": "Трубопроводи для відгалуження \"{branch}\"", "protected": { "approve": "Затвердити", "approve_success": "Трубопровід схвалено", "awaits": "Цей трубопровід чекає на погодження якогось експлуатаційника!", "decline": "Спад", "decline_success": "Трубопровід відхилено", "declined": "Від цього газопроводу відмовилися!" }, "step_not_started": "Цей крок ще не розпочався.", "tasks": "Задачі", "pipelines_for_pr": "Конвеєри для запиту на пул #{index}", "status": { "blocked": "заблоковано", "pending": "на розгляді", "error": "помилка", "failure": "невдача" }, "errors": "Помилки", "warnings": "Попередження" }, "settings": { "actions": { "actions": "Дії", "delete": { "confirm": "Всі дані будуть втрачені після цієї дії!!!\n\nВи дійсно хочете продовжити?", "delete": "Видалити сховище", "success": "Репозиторій видалено" }, "disable": { "disable": "Відключити репозиторій", "success": "Репозиторій відключено" }, "repair": { "repair": "Ремонтний репозиторій", "success": "Сховище відремонтовано" }, "enable": { "enable": "Увімкнути репозиторій", "success": "Репозиторій увімкнено" } }, "badge": { "badge": "Бейдж", "branch": "Філія", "type": "Синтаксис", "type_html": "HTML", "type_markdown": "Уцінка", "type_url": "URL" }, "crons": { "add": "Додати cron", "branch": { "placeholder": "Гілка (використовує гілку за замовчуванням, якщо порожня)", "title": "Філія" }, "created": "Створено Cron", "crons": "Крони", "delete": "Видалити cron", "deleted": "Cron видалено", "desc": "Завдання Cron можна використовувати для регулярного запуску трубопроводів.", "edit": "Редагувати cron", "name": { "name": "Назва", "placeholder": "Назва cron завдання" }, "next_exec": "Наступне виконання", "none": "Крон поки що немає.", "not_executed_yet": "Ще не виконано", "save": "Зберегти cron", "saved": "Cron збережено", "schedule": { "placeholder": "Розклад", "title": "Розклад (на основі UTC)" }, "show": "Показати крони", "run": "Виконати зараз" }, "general": { "allow_pr": { "allow": "Дозволити запити на витяг", "desc": "Конвеєри можуть працювати на основі запитів." }, "cancel_prev": { "cancel": "Скасувати попередні трубопроводи", "desc": "Дозволяє скасовувати відкладені та запущені конвеєри однієї і тієї ж події та контексту перед запуском нового конвеєра." }, "general": "Генерал", "pipeline_path": { "default": "За замовчуванням: .woodpecker/*.yml -> .woodpecker.yml", "path": "Траса трубопроводу", "desc_path_example": "мій/шлях/" }, "project": "Налаштування проекту", "protected": { "desc": "Кожен трубопровід повинен бути схвалений перед початком будівництва.", "protected": "Захищений" }, "save": "Зберегти настройки", "success": "Оновлено налаштування репозиторію", "timeout": { "minutes": "хвилини", "timeout": "Таймаут" }, "trusted": { "desc": "Кожен трубопровід повинен бути схвалений перед початком будівництва.", "trusted": "Довірені" }, "visibility": { "internal": { "desc": "Цей проект можуть бачити лише авторизовані користувачі інстанції Woodpecker.", "internal": "Внутрішній" }, "private": { "desc": "Цей проект можете бачити тільки ви та інші власники сховища.", "private": "Приватний" }, "public": { "desc": "Кожен користувач може побачити ваш проект, не входячи в систему.", "public": "Публічні" }, "visibility": "Прозорість проекту" }, "netrc_only_trusted": { "netrc_only_trusted": "Вставляйте облікові дані netrc лише в надійні контейнери", "desc": "Вставляйте облікові дані netrc лише в надійні контейнери (рекомендовано)." }, "allow_deploy": { "allow": "Дозволити розгортання", "desc": "Дозволити розгортання з успішних конвеєрів. Використовуйте лише в тому випадку, якщо ви довіряєте всім користувачам із доступом push." } }, "not_allowed": "Ви не маєте права доступу до налаштувань цього сховища", "registries": { "add": "Додати реєстр", "address": { "address": "Адреса", "placeholder": "Адреса реєстру (наприклад, docker.io)" }, "created": "Створено облікові дані реєстру", "credentials": "Реквізити реєстру", "delete": "Видалення реєстру", "deleted": "Видалено облікові дані реєстру", "desc": "Облікові дані реєстрів можуть бути додані для використання приватних зображень для вашого конвеєра.", "edit": "Редагування реєстру", "none": "Повноважень реєстру поки що немає.", "registries": "Реєстри", "save": "Зберегти реєстр", "saved": "Облікові дані реєстру збережено", "show": "Показати реєстри" }, "secrets": { "add": "Додати секрет", "created": "Секрет створено", "delete": "Видалити секрет", "deleted": "Секрет видалено", "desc": "Секрети можуть бути передані окремим етапам конвеєра під час виконання як змінні середовища.", "edit": "Секрет редагування", "events": { "events": "Доступно на наступних заходах", "pr_warning": "Будь ласка, будьте обережні з цією опцією, оскільки зловмисник може надіслати зловмисний запит, який розкриє ваші секрети." }, "images": { "desc": "Через кому список зображень, для яких доступний цей секрет, залишити порожнім, щоб дозволити всі зображення", "images": "Доступно для наступних зображень" }, "name": "Назва", "none": "Секретів поки що немає.", "save": "Зберегти секрет", "saved": "Секрет збережено", "secrets": "Секрети", "show": "Показати секрети", "value": "Значення", "delete_confirm": "Ви справді хочете видалити цей секрет?", "plugins_only": "Доступно лише для плагінів" }, "settings": "Налаштування" }, "user_none": "Ця організація/користувач ще не має проектів.", "pull_requests": "Запит на пул" }, "repos": "Репозиторії", "repositories": { "title": "Репозиторії", "all": { "title": "Всі репозиторії" } }, "search": "Обшук…", "time": { "days_short": "д", "hours_short": "г", "min_short": "хв", "not_started": "ще не розпочато", "sec_short": "сек", "template": "MMM D, РРРР, ГГ:п z", "weeks_short": "т", "just_now": "щойно" }, "unknown_error": "Виникла невідома помилка", "url": "URL", "user": { "access_denied": "Ви не авторизовані для входу", "internal_error": "Виникла внутрішня помилка", "oauth_error": "Помилка під час автентифікації у провайдера OAuth" }, "username": "Ім'я користувача", "welcome": "Ласкаво просимо до Woodpcker", "api": "API", "empty_list": "{entity} не знайдено!" } ================================================ FILE: web/src/assets/locales/zh-Hans.json ================================================ { "admin": { "settings": { "agents": { "add": "添加代理", "agents": "代理", "backend": { "backend": "后端", "badge": "后端" }, "capacity": { "badge": "容量", "capacity": "容量", "desc": "该 Agent 并行执行流水线的最大数量。" }, "created": "已创建代理", "delete_agent": "删除 Agent", "delete_confirm": "你真的要删除该 Agent 吗?删除后它将无法再次连接到此服务器。", "deleted": "已删除代理", "desc": "注册到此服务器的代理。", "edit_agent": "编辑 Agent", "id": "ID", "last_contact": { "last_contact": "上次通信", "badge": "上次通信" }, "name": { "name": "名称", "placeholder": "代理名称" }, "never": "从未", "no_schedule": { "name": "禁用代理", "placeholder": "阻止代理接受新任务" }, "none": "还未添加任何代理。", "platform": { "badge": "平台", "platform": "平台" }, "save": "保存代理", "saved": "已保存代理", "show": "查看代理", "token": "Token", "version": "版本", "custom_labels": { "custom_labels": "自定义标签", "desc": "Agent 管理员在 agent 启动时设置的自定义标签。" }, "org": { "badge": "组织" } }, "not_allowed": "你没有访问服务器设置的权限", "orgs": { "delete_confirm": "你真的想删除该组织吗?这也将删除该组织拥有的所有存储库。", "delete_org": "删除组织", "deleted": "组织已删除", "desc": "在此服务器上拥有存储库的组织。", "none": "还没有任何组织。", "org_settings": "组织设置", "orgs": "组织", "view": "查看组织" }, "queue": { "agent": "agent", "desc": "正在等待 Agents 执行的任务。", "pause": "暂停", "paused": "队列已暂停", "queue": "队列", "resume": "恢复", "resumed": "队列已恢复", "stats": { "completed_count": "已完成的任务", "pending_count": "等待中", "running_count": "运行中", "waiting_on_deps_count": "等待依赖项", "worker_count": "空闲" }, "task_pending": "任务正在等待中", "task_running": "任务正在运行", "task_waiting_on_deps": "任务正在等待依赖项", "tasks": "任务", "waiting_for": "正在等待" }, "repos": { "desc": "该服务器上已启用或曾经启用的仓库。", "disabled": "已禁用", "none": "目前还没有存储库。", "repair": { "repair": "修复所有", "success": "仓库已修复" }, "repos": "仓库", "settings": "仓库设置", "view": "查看仓库" }, "secrets": { "add": "添加密钥", "created": "全局密钥已创建", "deleted": "全局密钥已删除", "desc": "全局密钥可用于任意仓库的各个流水线。", "events": { "events": "对以下事件可用", "pr_warning": "慎选这个选项:用户可以提交一个恶意推送请求以暴露你的密钥。" }, "images": { "desc": "此密钥可用于此处填写的镜像列表,用逗号分隔,留空以允许所有镜像", "images": "可用于以下镜像" }, "name": "名称", "none": "现在没有全局密钥。", "plugins_only": "仅对插件有效", "save": "保存密钥", "saved": "全局密钥已保存", "secrets": "密钥", "show": "显示所有密钥", "value": "值", "warning": "该密钥对所有用户可用。" }, "settings": "设置", "users": { "add": "添加用户", "admin": { "admin": "管理员", "placeholder": "此用户是管理员" }, "avatar_url": "头像链接", "cancel": "取消", "created": "用户已创建", "delete_confirm": "你真的想删除这个用户吗?这也将删除该用户拥有的所有存储库。", "delete_user": "删除用户", "deleted": "删除用户", "desc": "在此服务器上注册的用户。", "edit_user": "编辑用户", "email": "邮箱", "login": "登录", "none": "还没有任何用户。", "save": "保存用户", "saved": "已保存用户", "show": "显示用户", "users": "用户" }, "registries": { "desc": "可以添加全局注册凭据以在所有流水线中使用私有镜像。", "warning": "这些注册凭据对所有用户可用。" } } }, "api": "API", "back": "返回", "cancel": "取消", "default": "默认", "docs": "文档", "documentation_for": "\"{topic}\"的文档", "errors": { "not_found": "服务器找不到请求的对象" }, "info": "信息", "login": "登录", "logout": "退出登录", "not_found": { "back_home": "回到主页", "not_found": "啊,404,我们搞砸了什么,或者您拼错了什么词 :-/" }, "org": { "settings": { "not_allowed": "你没有访问组织设置的权限", "secrets": { "add": "添加密钥", "created": "组织密钥已创建", "deleted": "组织密钥已删除", "desc": "组织密钥可用于该组织所有仓库的各个流水线。", "events": { "events": "对以下事件可用", "pr_warning": "慎选这个选项:用户可以提交一个恶意推送请求以暴露你的密钥。" }, "images": { "desc": "此密钥可用于此处填写的镜像列表,用逗号分隔,留空以允许所有镜像", "images": "可用于以下镜像" }, "name": "名称", "none": "没有组织密钥。", "plugins_only": "仅对插件有效", "save": "保存密钥", "saved": "组织密钥已保存", "secrets": "密钥", "show": "显示所有密钥", "value": "值" }, "settings": "设置", "agents": { "desc": "注册到此组织代理。" }, "registries": { "desc": "可以为组织添加注册表凭据,以便在所有流水线中使用私有镜像。" } } }, "password": "密码", "pipeline_feed": "流水线视图", "repo": { "activity": "活动", "add": "添加仓库", "branches": "分支", "deploy_pipeline": { "enter_target": "目标部署环境", "title": "触发当前流水线 #{pipelineId} 的部署", "trigger": "部署", "variables": { "add": "添加变量", "desc": "指定传递到流水线中的变量。相同名称的变量会被覆盖。", "name": "变量名", "title": "添加流水线变量", "value": "变量值", "delete": "删除变量" }, "enter_task": "部署任务" }, "enable": { "disabled": "已禁用", "enable": "启用", "enabled": "已经启用了", "list_reloaded": "仓库列表已刷新", "reload": "刷新项目列表", "success": "此仓库已成功启用", "new_forge_repo": "代码托管平台上的新仓库", "stale_wp_repo": "Woodpecker 仓库已过期", "conflict": "冲突", "conflict_desc": "该仓库已在代码托管平台上使用新 ID 重新创建,但 Woodpecker 中仍存在一个同名的过期记录。请删除过期记录以启用新记录,或者修复旧记录。", "forge_repo_missing": "代码托管平台仓库已缺失!" }, "manual_pipeline": { "select_branch": "选择分支", "title": "手动触发流水线运行", "trigger": "运行流水线", "variables": { "add": "添加变量", "desc": "指定要添加在流水线中使用的变量,相同名称的变量将被覆盖。", "name": "变量名称", "title": "添加流水线变量", "value": "变量值", "delete": "删除变量" }, "show_pipelines": "查看流水线", "no_manual_workflows": "未找到匹配的工作流。请确保至少有一个手动触发的工作流运行。" }, "not_allowed": "你没有权限访问这个仓库", "open_in_forge": "在代码托管平台中打开", "pipeline": { "actions": { "cancel": "取消", "cancel_success": "流水线已被取消", "canceled": "该步骤已被取消。", "deploy": "部署", "log_auto_scroll": "启用自动滚动", "log_auto_scroll_off": "禁用自动滚动", "log_download": "下载", "restart": "重启", "restart_success": "流水线已重启", "log_delete": "删除", "skipped": "此步骤已被跳过。" }, "config": "配置", "errors": "错误", "event": { "cron": "计划任务", "deploy": "部署", "manual": "手动", "pr": "合并请求", "push": "推送", "tag": "标签", "release": "发布", "pr_closed": "拉取请求已合并/关闭", "pr_metadata": "拉取请求元数据已改变" }, "exit_code": "退出代码 {exitCode}", "files": "修改的文件", "loading": "载入中…", "log_download_error": "下载日志文件时发生了错误", "log_title": "步骤日志", "no_files": "没有文件修改。", "no_pipeline_steps": "没有流水线步骤可用!", "no_pipelines": "尚未启动过流水线。", "pipeline": "流水线 #{pipelineId}", "pipelines_for": "{branch} 分支的流水线", "pipelines_for_pr": "来自合并请求 #{index} 的流水线", "protected": { "approve": "同意", "approve_success": "流水线已被许可", "awaits": "该流水线正在等待维护者许可!", "decline": "拒绝", "decline_success": "流水线已被拒绝", "declined": "该流水线已被拒绝!", "review": "审查变更" }, "show_errors": "显示错误", "status": { "blocked": "已屏蔽", "declined": "已拒绝", "error": "错误", "failure": "失败", "killed": "已终止", "pending": "等待中", "running": "运行中", "skipped": "已跳过", "started": "已启动", "status": "状态:{status}", "success": "成功", "canceled": "已取消" }, "step_not_started": "该步骤尚未启动。", "tasks": "任务", "warnings": "警告", "we_got_some_errors": "哦不,发生了一些错误!", "no_logs": "无日志", "created": "创建于:{created}", "debug": { "title": "调试", "download_metadata": "下载元数据", "metadata_download_error": "下载元数据时出错", "metadata_download_successful": "元数据下载成功", "no_permission": "您无权访问调试信息", "metadata_exec_title": "本地重新运行流水线", "metadata_exec_desc": "下载此流水线的元数据以便在本地运行。这样可以在提交更改前修复问题并进行测试。Woodpecker CLI 必须与服务器版本一致并已在本地安装。" }, "duration": "流水线耗时:{duration}", "log_delete_confirm": "您确定要删除步骤日志吗?", "log_delete_error": "删除步骤日志时发生了错误", "view": "查看流水线", "cancel_info": { "superseded_by": "#{pipelineId} 已取消", "canceled_by_user": "{user} 已取消", "canceled_by_step": "因 {step} 而取消" }, "load_more": "加载更多", "version": "此流水线在该 Woodpecker 版本上执行。", "version_header": "Woodpecker 版本" }, "pull_requests": "合并请求", "settings": { "actions": { "actions": "操作", "delete": { "confirm": "该操作将会删除所有数据!\n\n你确定真的要继续吗?", "delete": "删除仓库", "success": "仓库已删除" }, "disable": { "disable": "禁用仓库", "success": "仓库已禁用" }, "enable": { "enable": "启用仓库", "success": "此仓库已成功启用" }, "repair": { "repair": "修复仓库", "success": "仓库已修复" } }, "badge": { "badge": "徽标", "branch": "分支", "type": "语法", "type_html": "HTML", "type_markdown": "Markdown", "type_url": "URL", "events": "事件", "step": "步骤", "workflow": "工作流" }, "crons": { "add": "创建计划", "branch": { "placeholder": "分支(若为空,将使用默认分支)", "title": "分支" }, "created": "计划已创建", "crons": "计划", "delete": "删除计划", "deleted": "计划已删除", "desc": "计划作业可用于定期触发流水线。", "edit": "编辑计划", "name": { "name": "名称", "placeholder": "此计划的名称" }, "next_exec": "下次执行", "none": "还未添加任何计划。", "not_executed_yet": "还没有执行过", "run": "立即执行", "save": "保存计划", "saved": "计划已保存", "schedule": { "placeholder": "计划任务", "title": "计划任务(基于 UTC)" }, "show": "显示所有计划", "enabled": "启用" }, "general": { "allow_pr": { "allow": "允许拉取请求", "desc": "允许运行拉取请求中的流水线。" }, "cancel_prev": { "cancel": "取消之前的流水线", "desc": "启用后,启动新触发的流水线之前会取消相同事件和上下文的挂起和正在运行的流水线。" }, "general": "项目", "netrc_only_trusted": { "desc": "可获取用于克隆或推送仓库至代码托管平台的 netrc 凭证的插件。", "netrc_only_trusted": "自定义信任的插件" }, "pipeline_path": { "default": "默认: .woodpecker/*.yml -> .woodpecker.yml", "desc": "流水线配置文件的路径(例如 {0})。文件夹路径应以 {1} 结尾。", "desc_path_example": "my/path/", "path": "流水线路径" }, "project": "项目设置", "protected": { "desc": "每个流水线都需要在执行之前获得批准。", "protected": "受保护" }, "save": "保存设置", "success": "项目设置已更新", "timeout": { "minutes": "分钟", "timeout": "流水线最大执行时长" }, "trusted": { "desc": "流水线容器可以使用高级功能,比如挂载卷。", "trusted": "受信任", "network": { "network": "网络", "desc": "流水线容器可以获得网络权限,例如更改 DNS。" }, "security": { "security": "安全", "desc": "流水线容器可以获得安全权限。" }, "volumes": { "volumes": "卷", "desc": "流水线容器允许被挂载卷。" } }, "visibility": { "internal": { "desc": "只有经过身份验证的 Woodpecker 用户才能看到此项目。", "internal": "内部" }, "private": { "desc": "只有你和此仓库的所有者可以看到此项目。", "private": "私有" }, "public": { "desc": "用户无需登录即可看到你的项目。", "public": "公开" }, "visibility": "项目可见性" }, "allow_deploy": { "allow": "允许部署", "desc": "允许成功的流水线进行部署。所有具有推送权限的用户都可以触发这些操作,因此请谨慎使用。" } }, "not_allowed": "你没有权限访问此仓库的设置", "registries": { "add": "添加 Registry", "address": { "address": "地址", "placeholder": "Registry 地址(如 docker.io)" }, "created": "Registry 密码已创建", "credentials": "Registry 凭据", "delete": "删除 registry", "deleted": "Registry 密码已删除", "desc": "可以添加 Registry 密码,以在流水线中使用私有镜像。", "edit": "编辑 Registry", "none": "现在没有 Registry 密码。", "registries": "注册表", "save": "保存 Registry", "saved": "Registry 密码已保存", "show": "显示 Registry" }, "secrets": { "add": "添加秘密", "created": "秘密已创建", "delete": "删除密钥", "delete_confirm": "你真的要删除这个密钥吗?", "deleted": "秘密已删除", "desc": "密钥可以在运行时作为环境变量传递给各个流水线步骤。", "edit": "编辑密钥", "events": { "events": "对以下事件可用", "pr_warning": "慎选这个选项:用户可以提交一个恶意推送请求以暴露你的密钥。" }, "images": { "desc": "此秘密可用于此处填写的镜像列表,用逗号分隔,留空以允许所有镜像", "images": "可用于以下镜像" }, "name": "名称", "none": "还未添加任何秘密。", "plugins_only": "仅对插件有效", "save": "保存秘密", "saved": "秘密已保存", "secrets": "密钥", "show": "显示所有秘密", "value": "值" }, "settings": "设置" }, "user_none": "该组织/用户尚无项目", "visibility": { "visibility": "项目可见性", "public": { "desc": "任何用户无需登录即可看到你的项目。", "public": "公共的" }, "private": { "private": "私有的", "desc": "只有你和其他仓库拥有者才能看到这个项目。" }, "internal": { "internal": "内部的", "desc": "只有Woodpecker的登录用户可以看到这个项目。" } } }, "repos": "仓库", "repositories": { "title": "仓库", "all": { "title": "所有仓库", "desc": "所有仓库(按流水线创建时间排序)" }, "last": { "title": "上次访问", "desc": "最近访问的仓库(按访问时间排序)" } }, "running_version": "你正在运行 Woodpecker {0}", "search": "查找仓库…", "time": { "days_short": "天", "hours_short": "小时", "min_short": "分", "not_started": "还没有运行过", "sec_short": "秒", "template": "YYYY 年 MM 月 D 日 HH:mm z", "weeks_short": "周", "just_now": "刚刚" }, "unknown_error": "发生了未知错误", "update_woodpecker": "请更新你的 Woodpecker 实例到 {0}", "url": "URL", "user": { "access_denied": "你没有登陆的权限", "internal_error": "发生了一些内部错误", "oauth_error": "Oauth 认证错误", "settings": { "api": { "api": "API", "api_usage": "API 用例", "cli_usage": "CLI 用例", "desc": "个人访问令牌和 API 使用", "dl_cli": "下载 CLI", "reset_token": "重置令牌", "shell_setup": "Shell 设置", "shell_setup_before": "在 shell 设置步骤之前", "swagger_ui": "Swagger UI", "token": "个人访问令牌" }, "general": { "general": "账户设置", "language": "语言", "theme": { "auto": "自动", "dark": "暗色", "light": "亮色", "theme": "主题" } }, "secrets": { "add": "添加密钥", "created": "已创建用户密钥", "deleted": "已删除用户密钥", "desc": "用户密钥可用于该用户所有仓库的各个流水线。", "events": { "events": "对以下事件可用", "pr_warning": "慎选这个选项:用户可以提交一个恶意推送请求以暴露你的密钥。" }, "images": { "desc": "此密钥可用于此处填写的镜像,多个镜像请使用逗号分隔,留空则允许所有镜像", "images": "可用于以下镜像" }, "name": "名称", "none": "还未添加任何密钥。", "plugins_only": "仅对插件有效", "save": "保存密钥", "saved": "已保存用户密钥", "secrets": "密钥", "show": "显示所有密钥", "value": "值" }, "settings": "用户设置", "cli_and_api": { "token": "个人访问令牌", "api_usage": "API 用法示例", "cli_usage": "CLI 用法示例", "download_cli": "下载 CLI", "reset_token": "重置令牌", "swagger_ui": "Swagger 界面", "desc": "个人访问令牌、CLI 和 API 用法", "cli_and_api": "CLI & API" }, "registries": { "desc": "可以添加用户注册表凭据,以便在所有个人流水线中使用私有镜像。" }, "agents": { "desc": "在您的账号仓库中已注册的 Agent。" } } }, "username": "用户名", "welcome": "欢迎来到 Woodpecker", "empty_list": "找不到 {entity} !", "login_to_cli": "登录到 CLI", "global_level_secret": "全局密钥", "org_level_secret": "组织密钥", "abort": "中止", "cli_login_success": "登录到 CLI 成功", "cli_login_failed": "登录到 CLI 失败", "cli_login_denied": "登录 CLI 被拒绝", "login_to_cli_description": "点击继续以登录到 CLI。", "return_to_cli": "您现在可以关闭此选项卡并返回 CLI。", "login_with": "使用 {forge} 登录", "require_approval": { "allowed_users": { "allowed_users": "已允许的用户", "desc": "由列表中用户创建的流水线无需批准。" }, "none": "无", "none_desc": "所有事件都会触发管道,包括拉取请求。此设置可能很危险,仅建议用于私有实例。", "pull_requests": "所有拉取请求", "forks": "从已 fork 的仓库进行 Pull request", "desc": "通过执行前批准以防止恶意流水线暴露密钥或运行有害的任务。", "require_approval_for": "批准要求", "all_events": "代码托管平台的所有事件" }, "secrets": { "secrets": "密钥", "add": "添加密钥", "save": "保存密钥", "show": "显示密钥", "created": "密钥已创建", "saved": "密钥已保存", "edit": "编辑密钥", "delete": "删除密钥", "name": "名称", "value": "值", "deleted": "密钥已删除", "delete_confirm": "您确实要删除这个密钥吗?", "events": { "events": "适用于以下事件", "warning": "向所有拉取请求暴露密钥可能导致密钥被恶意拉取请求窃取。" }, "none": "目前没有密钥。", "desc": "密钥可以被用于这个仓库的所有流水线中。", "plugins": { "images": "仅适用于以下的插件", "desc": "该密钥生效的插件镜像列表。置空以允许所有插件和常规步骤。" }, "note": "备注" }, "no_search_results": "未找到结果", "org_access_denied": "您无权访问此组织", "invalid_state": "OAuth 状态无效", "internal_error": "发生内部错误", "settings": "设置", "access_denied": "您无权访问此实例", "registries": { "address": { "address": "地址", "desc": "注册表地址(如 docker.io)" }, "deleted": "已删除注册表凭据", "show": "显示注册表", "save": "保存注册表", "add": "添加注册表", "view": "查看注册表", "edit": "编辑注册表", "delete": "删除注册表", "created": "已创建注册表凭据", "registries": "注册表", "delete_confirm": "您真的要删除此注册表吗?", "credentials": "注册表凭据", "desc": "在流水线中可添加注册表凭据使用私有镜像。", "none": "目前还没有注册表凭据。", "saved": "已保存注册表凭据" }, "registration_closed": "已关闭注册", "oauth_error": "与 OAuth 提供商进行身份验证时出错", "public_url_for_oauth_if": "如果用于 OAuth 的对外可访问地址与当前系统的基础 URL 不同,请在({0})填入", "edit_forge": "编辑代码托管平台", "forge_delete_confirm": "您真的想要删除这个代码托管平台吗?这将也会删除与此代码托管平台相关的所有仓库、用户和流水线。", "no_forges": "还没有配置任何代码托管平台.", "developer_settings": "开发者设置", "delete_forge": "删除代码托管平台", "login_to_woodpecker_with": "登录 Woodpecker 使用", "extensions": "扩展程序", "extensions_description": "扩展程序是可被 Woodpecker 调用的 HTTP 服务。", "extension_endpoint_placeholder": "e.g. https://example.com/api", "config_extension_endpoint": "配置扩展程序端点", "extensions_signatures_public_key": "用于签名的公钥", "extensions_signatures_public_key_description": "扩展程序应使用此公钥检验请求是否由 Woodpecker 发起。", "extensions_configuration_saved": "扩展程序配置已保存", "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "bitbucket_dc": "Bitbucket Data Center", "gitea": "Gitea", "forgejo": "Forgejo", "save": "保存", "add": "添加", "skip_verify": "跳过 SSL 验证", "skip_verify_desc": "为 API 请求跳过 SSL 验证。不建议用于生产环境。", "forge_created": "已创建代码托管平台", "advanced_options": "高级选项", "leave_empty_to_keep_current_value": "置空以保留原值", "forge_deleted": "已删除代码托管平台", "oauth_redirect_url": "OAuth 重定向 URL", "forge_managed_by_env": "主要代码托管平台由环境变量控制。任何主要代码托管平台的更改将在重启时重置。", "use_this_redirect_url_to_create": "用这个重定向 URL 来创建或更新 OAuth 应用。", "forge_saved": "已保存代码托管平台", "fullscreen": "全屏", "exit_fullscreen": "退出全屏", "help_translating": "您可以在 {0} 帮助翻译 Woodpecker 为您的语言。", "weblate": "我们的 Weblate", "disabled": "禁用", "forges": "代码托管平台", "forges_desc": "配置 Woodpecker 为其工作的代码托管平台。", "add_forge": "添加代码托管平台", "show_forges": "显示代码托管平台", "addon": "Addon", "forge_type": "代码托管平台类型", "oauth_client_id": "OAuth Client ID", "oauth_client_secret": "OAuth Client Secret", "oauth_host": "OAuth host", "merge_ref": "合并 ref", "merge_ref_desc": "用于合并的 ref。这被用于确定拉取请求的 diff。", "public_only": "仅公开", "public_only_desc": "仅显示公开仓库。", "git_username": "Git 用户名", "git_username_desc": "Git 用户的用户名。", "git_password": "Git 密码", "git_password_desc": "Git 用户的密码或通行密钥(personal access token)。", "executable": "可执行文件", "executable_desc": "Addon 的可执行文件的路径。", "developer_settings_to_create": "前往 {0} 并设置 OAuth 应用。", "config_extension_exclusive": "独占", "config_extension_exclusive_desc": "启用后,将跳过所有其他代码托管平台的配置获取方式。", "registry_extension_endpoint": "注册扩展程序端点", "global_level_registry": "全局注册表", "org_level_registry": "组织注册表", "secret_extension_endpoint": "密钥扩展程序端点", "secret_extension_netrc": "包含 netrc 凭证", "secret_extension_netrc_desc": "将代码托管平台 netrc 凭证发送给密钥扩展程序。", "extension_netrc": "包含 netrc 凭证", "extension_netrc_desc": "向扩展程序发送代码托管平台的 netrc 凭证。" } ================================================ FILE: web/src/assets/locales/zh-Hant.json ================================================ { "api": "API", "back": "返回", "login": "登入", "logout": "登出", "password": "密碼", "repos": "儲存庫", "repositories": { "title": "儲存庫", "all": { "title": "所有儲存庫" }, "last": { "title": "上次瀏覽" } }, "search": "搜尋…", "unknown_error": "出現未知錯誤", "url": "URL", "welcome": "歡迎使用 Woodpecker", "repo": { "manual_pipeline": { "select_branch": "選擇分支", "show_pipelines": "顯示 pipeline", "title": "手動觸發 pipeline 執行", "trigger": "執行 pipeline", "variables": { "value": "變數值", "title": "額外 pipeline 變數", "desc": "指定在 pipeline 中額外使用的變數。相同變數名稱會被覆蓋掉。", "name": "變數名稱", "delete": "刪除變數" } }, "pipeline": { "actions": { "cancel": "取消", "restart": "重新啟動", "deploy": "部署", "log_download": "下載", "log_delete": "刪除" }, "tasks": "任務", "status": { "skipped": "已略過", "success": "成功", "failure": "失敗", "error": "錯誤", "running": "執行中", "pending": "等待中", "declined": "已拒絕" }, "load_more": "載入更多", "config": "配置", "event": { "deploy": "部署", "push": "推送", "tag": "標記", "manual": "手動", "release": "發佈" }, "errors": "錯誤", "warnings": "警告", "show_errors": "顯示錯誤", "debug": { "title": "除錯", "download_metadata": "下載詮釋資料", "metadata_download_successful": "詮釋資料下載成功" }, "no_logs": "無紀錄", "loading": "載入中…", "exit_code": "退出代碼 {exitCode}", "pipeline": "Pipeline #{pipelineId}", "protected": { "approve": "核准", "decline": "拒絕" }, "version_header": "Woodpecker 版本", "created": "", "view": "檢視 pipeline", "cancel_info": { "canceled_by_step": "因為 {step} 所以被取消", "canceled_by_user": "被 {user} 取消" } }, "settings": { "general": { "save": "儲存設定", "timeout": { "timeout": "逾時", "minutes": "分鐘" }, "general": "專案", "project": "專案設定", "allow_deploy": { "allow": "允許部署" }, "trusted": { "security": { "security": "安全" }, "network": { "network": "網路" }, "volumes": { "volumes": "儲存區" } }, "pipeline_path": { "path": "Pipeline 路徑", "desc_path_example": "my/path/" }, "allow_pr": { "allow": "允許拉取請求" } }, "badge": { "branch": "分支", "badge": "徽章", "type": "語法", "type_url": "URL", "type_markdown": "Markdown", "type_html": "HTML", "events": "事件", "workflow": "工作流程" }, "crons": { "schedule": { "placeholder": "排程" }, "run": "現在執行", "branch": { "title": "分支" }, "name": { "name": "名稱" }, "enabled": "已啟用" }, "actions": { "repair": { "repair": "修復儲存庫", "success": "儲存庫已修復" }, "disable": { "disable": "停用儲存庫", "success": "儲存庫已停用" }, "enable": { "enable": "啟用儲存庫", "success": "儲存庫已啟用" }, "delete": { "delete": "刪除儲存庫", "success": "儲存庫已刪除" } } }, "visibility": { "visibility": "專案可見性", "public": { "public": "公開", "desc": "所有人包含非登入的使用者都可以看到你的專案。" }, "private": { "private": "私有", "desc": "只有你和其他儲存庫的擁有者可以看到這個專案。" }, "internal": { "internal": "內部的", "desc": "只有已認證的使用者可以看到這個專案。" } }, "deploy_pipeline": { "title": "觸發當前 pipeline #{pipelineId} 的部署", "enter_target": "目標部署環境", "variables": { "title": "額外 pipeline 變數", "delete": "刪除變數", "name": "變數名稱", "value": "變數值" }, "trigger": "部署", "enter_task": "部署任務" }, "pull_requests": "拉取請求", "add": "新增儲存庫", "user_none": "這個組織/使用者還沒有任何專案", "not_allowed": "你不被允許存取這個儲存庫", "enable": { "enable": "啟用", "enabled": "已啟用", "disabled": "已停用", "success": "儲存庫已啟用", "stale_wp_repo": "過時的 Woodpecker 儲存庫", "conflict": "衝突" }, "open_in_forge": "", "activity": "活動", "branches": "分支" }, "cancel": "取消", "not_found": { "back_home": "返回首頁" }, "admin": { "settings": { "users": { "cancel": "取消", "login": "登入", "email": "電子郵件", "avatar_url": "Avatar URL", "save": "儲存使用者", "show": "顯示使用者", "add": "新增使用者", "admin": { "admin": "管理員" }, "delete_user": "刪除使用者", "edit_user": "編輯使用者", "users": "使用者", "deleted": "使用者已刪除", "created": "使用者已建立", "saved": "使用者已儲存" }, "agents": { "name": { "name": "名稱" }, "id": "ID", "token": "權杖", "platform": { "platform": "平臺", "badge": "平臺" }, "backend": { "backend": "後端", "badge": "後端" }, "capacity": { "capacity": "容量", "badge": "容量" }, "version": "版本", "org": { "badge": "組織" } }, "repos": { "disabled": "已停用", "settings": "儲存庫設定", "repos": "儲存庫", "view": "檢視儲存庫" }, "queue": { "queue": "佇列", "pause": "暫停", "resume": "恢復", "paused": "佇列已暫停", "resumed": "佇列已恢復", "tasks": "任務", "stats": { "running_count": "執行中", "pending_count": "等待中" }, "task_running": "任務執行中", "task_pending": "任務等待中", "waiting_for": "正在等待" }, "orgs": { "orgs": "組織", "delete_org": "刪除組織", "org_settings": "組織設定", "deleted": "組織已刪除", "view": "檢視組織" }, "settings": "管理員設定" } }, "username": "帳號", "time": { "just_now": "剛剛", "not_started": "尚未開始" }, "empty_list": "找不到 {entity}!", "default": "預設", "info": "資訊", "login_to_woodpecker_with": "使用以下方式登入 Woodpecker", "docs": "說明文件", "secrets": { "name": "名稱", "value": "值", "secrets": "秘密", "add": "新增秘密", "save": "儲存秘密", "show": "顯示秘密", "deleted": "秘密已刪除", "created": "秘密已建立", "saved": "秘密已儲存", "edit": "編輯秘密", "delete": "刪除秘密", "note": "說明" }, "settings": "設定", "extensions": "擴充功能", "require_approval": { "none": "無", "require_approval_for": "批准要求", "pull_requests": "所有拉取請求" }, "github": "GitHub", "gitlab": "GitLab", "bitbucket": "Bitbucket", "gitea": "Gitea", "forgejo": "Forgejo", "save": "儲存", "add": "新增", "skip_verify": "略過 SSL 驗證", "advanced_options": "進階選項", "git_username": "Git 使用者名稱", "git_password": "Git 密碼", "oauth_redirect_url": "OAuth 重新導向 URL", "fullscreen": "全螢幕", "exit_fullscreen": "退出全螢幕", "weblate": "我們的 Weblate", "disabled": "已停用", "user": { "settings": { "settings": "使用者設定", "general": { "general": "帳號", "language": "語言", "theme": { "theme": "主題", "light": "亮色", "dark": "暗色", "auto": "自動" } }, "cli_and_api": { "download_cli": "下載 CLI", "reset_token": "重設權杖", "swagger_ui": "Swagger UI", "api_usage": "API 使用範例", "cli_usage": "CLI 使用範例", "token": "個人存取權杖", "cli_and_api": "CLI & API" } } }, "registries": { "address": { "address": "位址", "desc": "註冊表位址 (例如 docker.io)" }, "registries": "註冊表", "credentials": "註冊表憑證", "show": "顯示註冊表", "save": "儲存註冊表", "add": "新增註冊表", "view": "檢視註冊表", "edit": "編輯註冊表", "delete": "刪除註冊表" }, "developer_settings": "開發者設定", "oauth_host": "OAuth 主機", "extension_endpoint_placeholder": "例如 https://example.com/api", "global_level_registry": "全域註冊表", "org_level_registry": "組織註冊表", "bitbucket_dc": "Bitbucket Data Center", "public_only": "僅公開", "public_only_desc": "僅顯示公開儲存庫。", "addon": "附加元件", "config_extension_exclusive": "獨佔", "abort": "中止" } ================================================ FILE: web/src/components/FileTree.vue ================================================ ================================================ FILE: web/src/components/admin/settings/forges/AdminForgeForm.vue ================================================ ================================================ FILE: web/src/components/admin/settings/queue/AdminQueueStats.vue ================================================ ================================================ FILE: web/src/components/agent/AgentForm.vue ================================================ ================================================ FILE: web/src/components/agent/AgentList.vue ================================================ ================================================ FILE: web/src/components/agent/AgentManager.vue ================================================ ================================================ FILE: web/src/components/atomic/Badge.vue ================================================ ================================================ FILE: web/src/components/atomic/Button.vue ================================================ ================================================ FILE: web/src/components/atomic/CountBadge.vue ================================================ ================================================ FILE: web/src/components/atomic/DocsLink.vue ================================================ ================================================ FILE: web/src/components/atomic/Error.vue ================================================ ================================================ FILE: web/src/components/atomic/Icon.vue ================================================ ================================================ FILE: web/src/components/atomic/IconButton.vue ================================================ ================================================ FILE: web/src/components/atomic/ListItem.vue ================================================ ================================================ FILE: web/src/components/atomic/RenderMarkdown.vue ================================================ ================================================ FILE: web/src/components/atomic/SvgIcon.vue ================================================ ================================================ FILE: web/src/components/atomic/SyntaxHighlight.ts ================================================ import '~/style/prism.css'; import Prism from 'prismjs'; import { computed, defineComponent, h, toRef } from 'vue'; import type { VNode } from 'vue'; declare type Data = Record; export default defineComponent({ name: 'SyntaxHighlight', props: { code: { type: String, default: '', }, language: { type: String, default: 'yaml', }, }, setup(props, { attrs }: { attrs: Data }) { const code = toRef(props, 'code'); const language = toRef(props, 'language'); const prismLanguage = computed(() => Prism.languages[language.value]); const className = computed(() => `language-${language.value}`); return (): VNode => h('pre', { ...attrs, class: [attrs.class, className] }, [ h('code', { class: className, innerHTML: Prism.highlight(code.value, prismLanguage.value, language.value), }), ]); }, }); ================================================ FILE: web/src/components/atomic/Warning.vue ================================================ ================================================ FILE: web/src/components/form/Checkbox.vue ================================================ ================================================ FILE: web/src/components/form/CheckboxesField.vue ================================================ ================================================ FILE: web/src/components/form/InputField.vue ================================================ ================================================ FILE: web/src/components/form/KeyValueEditor.vue ================================================ ================================================ FILE: web/src/components/form/NumberField.vue ================================================ ================================================ FILE: web/src/components/form/RadioField.vue ================================================ ================================================ FILE: web/src/components/form/SelectField.vue ================================================ ================================================ FILE: web/src/components/form/TextField.vue ================================================